Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Sources/LaboLaboEngine/Agent/AgentStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,23 @@ public struct AgentStatusEvent: Sendable {
public let sessionID: String?
public let transcriptPath: String?
public let cwd: String?
/// フォワーダが環境変数 LABOLABO_PANE から付与した端末ペイン ID(UUID 文字列)。
/// LaboLabo が生成した端末以外(外部ターミナル等)からのイベントでは nil。
public let paneID: String?

public init(
hookEvent: String,
status: AgentStatus,
sessionID: String?,
transcriptPath: String?,
cwd: String?
cwd: String?,
paneID: String? = nil
) {
self.hookEvent = hookEvent
self.status = status
self.sessionID = sessionID
self.transcriptPath = transcriptPath
self.cwd = cwd
self.paneID = paneID
}
}
3 changes: 2 additions & 1 deletion Sources/LaboLaboEngine/Agent/AgentStatusBus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ public final class AgentStatusBus: @unchecked Sendable {
status: status,
sessionID: object["session_id"] as? String,
transcriptPath: object["transcript_path"] as? String,
cwd: object["cwd"] as? String
cwd: object["cwd"] as? String,
paneID: object["labolabo_pane_id"] as? String
)
DispatchQueue.main.async { [weak self] in
self?.onEvent?(event)
Expand Down
12 changes: 12 additions & 0 deletions Tests/LaboLaboEngineTests/AgentStatusBusTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ final class AgentStatusBusTests: XCTestCase {
XCTAssertEqual(event.sessionID, "s1")
XCTAssertEqual(event.transcriptPath, "/tmp/t.jsonl")
XCTAssertEqual(event.cwd, "/tmp")
// フォワーダ由来の pane id が無い(外部ターミナル等)場合は nil。
XCTAssertNil(event.paneID)
}

func testPaneIDIsParsedWhenForwarderAnnotates() throws {
// フォワーダが LABOLABO_PANE から付与する labolabo_pane_id がイベントへ載ること。
// タブ別 resume(session_id ↔ ペインの対応付け)の要。
let json = #"{"hook_event_name":"SessionStart","session_id":"s9","labolabo_pane_id":"ABC-123"}"#
let event = try XCTUnwrap(expectEvent(sending: json))
XCTAssertEqual(event.status, .starting)
XCTAssertEqual(event.sessionID, "s9")
XCTAssertEqual(event.paneID, "ABC-123")
}

func testStopEventRoundTripEmitsIdle() throws {
Expand Down
6 changes: 6 additions & 0 deletions app/Sources/AgentSessionModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ final class AgentSessionModel {
private let onSessionID: ((String, String?) -> Void)?
/// 状態が変化したときに呼ばれる(入力待ちの通知などに使う)。
private let onStatusChange: ((AgentStatus) -> Void)?
/// (sessionID, ペイン UUID 文字列, transcript パス) を受信したら呼ばれる。端末タブごとの
/// Claude セッション対応付け(タブ別 --resume)に使う。UI 層(SessionDetailView)が
/// 表示時に差し込むため、init 引数ではなく差し替え可能な var にしている。
@ObservationIgnored var onPaneSessionID: ((String, String, String?) -> Void)?

/// resume に使う ID。今回受信済みなら最新、無ければ前回永続化分。
private var resumeID: String? { lastSessionID ?? initialResumeID }
Expand Down Expand Up @@ -82,6 +86,8 @@ final class AgentSessionModel {
if let id = event.sessionID {
lastSessionID = id
onSessionID?(id, event.transcriptPath) // 次回起動の --resume 用に永続化
// LaboLabo の端末から起動されたものはペイン ID が付く → タブと対応付け。
if let paneID = event.paneID { onPaneSessionID?(id, paneID, event.transcriptPath) }
}
if status != previous { onStatusChange?(status) }
// 応答完了/終了時に transcript から使用量を集計(推定)。
Expand Down
4 changes: 2 additions & 2 deletions app/Sources/BugReportSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,10 @@ struct BugReportSheet: View {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(issueBody, forType: .string)
withAnimation { copied = true }
withAnimation(LaboTheme.Motion.feedback) { copied = true }
Task {
try? await Task.sleep(for: .seconds(2))
withAnimation { copied = false }
withAnimation(LaboTheme.Motion.feedback) { copied = false }
}
}
}
143 changes: 115 additions & 28 deletions app/Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -325,55 +325,82 @@ struct SessionRow: View {
PRBadge(pr: pr)
}
// ホバー時に閉じる×を表示(サイドバー内で直接閉じる)。
if hovering {
Button(action: onClose) {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .semibold))
}
.buttonStyle(.borderless)
.foregroundStyle(.secondary)
.help("セッションを閉じる")
// if で挿入すると PRBadge や名前のトランケーションがホバーのたびに
// 横シフトするため、常駐させて opacity だけ切り替える(表示は即時)。
Button(action: onClose) {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .semibold))
}
.buttonStyle(.borderless)
.foregroundStyle(.secondary)
.help("セッションを閉じる")
.opacity(hovering ? 1 : 0)
.allowsHitTesting(hovering)
.accessibilityHidden(!hovering)
}
.padding(.vertical, 2)
.padding(.trailing, 8)
.onHover { hovering = $0 }
}
}

/// セッション行のエージェント状態ドット。進行中(起動中/実行中/入力待ち)は
/// パルスで目立たせ、待機(idle)は点灯のみ、未起動/終了は非表示。
/// セッション行のエージェント状態ドット。実行中はソナー風ピング、入力待ちは
/// 呼吸ハローで動きを変えて区別し、待機(idle)は点灯のみ、未起動/終了は非表示。
struct AgentStatusIndicator: View {
let status: AgentStatus
@State private var animate = false
@State private var windowVisible = true
@Environment(\.accessibilityReduceMotion) private var reduceMotion

var body: some View {
ZStack {
if let tint, pulses {
Circle()
.fill(tint.opacity(0.35))
.frame(width: 15, height: 15)
.scaleEffect(animate ? 1.0 : 0.4)
.opacity(animate ? 0 : 0.85)
// 進行中のアニメーション。ウィンドウが完全に隠れている間と Reduce Motion
// 有効時は階層から外して描画コストをゼロにする(状態はグロー付きドット
// だけでも伝わる)。駆動は PhaseAnimator: onAppear + repeatForever は
// ビューの初回挿入と同じトランザクションだとアニメが始まらないことが
// あるため、挿入タイミングに依存しない方式にする。
if let tint, windowVisible, !reduceMotion {
switch status {
case .starting, .running:
// ソナー風ピング: 「動き続けている」ことの表現。
Circle()
.fill(tint.opacity(0.35))
.frame(width: 15, height: 15)
.phaseAnimator([false, true]) { ring, expanded in
ring
.scaleEffect(expanded ? 1.0 : 0.4)
.opacity(expanded ? 0 : 0.85)
} animation: { expanded in
// 拡散は 1.1s ease-out、開始位置への戻りは無アニメ(リセット)。
expanded ? .easeOut(duration: 1.1) : nil
}
case .waitingForInput:
// 呼吸するハロー: ピングと動きの種類を変え、「入力を待っている」
// 注意喚起を琥珀色と併せて一目で区別できるようにする。
Circle()
.fill(tint.opacity(0.45))
.frame(width: 13, height: 13)
.phaseAnimator([false, true]) { halo, up in
halo
.scaleEffect(up ? 1.5 : 0.9)
.opacity(up ? 0.2 : 0.7)
} animation: { _ in
.easeInOut(duration: 0.55)
}
case .idle, .none, .ended:
EmptyView()
}
}
Circle()
.fill(tint ?? .clear)
.frame(width: 7, height: 7)
// 進行中はブランド色のグローで目立たせる。
.shadow(color: pulses ? (tint ?? .clear).opacity(0.8) : .clear, radius: 3)
// 状態遷移の色(とグロー)をクロスフェード。
.animation(LaboTheme.Motion.tint, value: status)
}
.frame(width: 12, height: 12)
.help(status == .none ? "" : status.label)
.onAppear { restartPulse() }
.onChange(of: status) { _, _ in restartPulse() }
}

private func restartPulse() {
animate = false
guard pulses else { return }
withAnimation(.easeOut(duration: 1.1).repeatForever(autoreverses: false)) {
animate = true
}
.background(WindowVisibilityReader(isVisible: $windowVisible))
}

/// 進行中はパルスさせる。
Expand Down Expand Up @@ -568,6 +595,49 @@ struct SessionDetailView: View {
configSource = GhosttyConfig.userConfigSource()
}

/// 起動復元されたセッションを初めて表示したとき、前回の Claude セッションを自動再開する。
/// pendingAutoResume は初回消費で false に戻る一時フラグ(RepoSession 側)。
/// タブ別のセッション ID が保存されていれば **各端末タブへそれぞれの --resume** を打ち、
/// 無ければ従来どおりセッション単位の ID を最初の端末へ(それも無ければ ✨ ボタンと
/// 同じ経路で新規端末に)フォールバックする。新規ペインはむやみに増やさない。
private func triggerAutoResumeIfNeeded() {
guard session.pendingAutoResume else { return }
session.pendingAutoResume = false // 1 回だけ
guard (UserDefaults.standard.object(forKey: "autoResumeAgentOnRestore") as? Bool) ?? true else { return }
// 既に動いていたら何もしない。.ended も許可するのは、アプリ終了時に死んだ旧 claude の
// SessionEnd hook が再起動直後の(同一パスで bind し直した)ソケットへ遅れて届き、
// status が .ended になって resume がスキップされるレースがあるため。
guard let agent = session.agent,
agent.status == .none || agent.status == .ended else { return }

// タブ別 resume: hooks の LABOLABO_PANE 対応付けで保存された各タブの ID を再開。
if agentAdapter.capabilities.resume {
let resumable = tiling.terminalPanes.filter { pane in
guard let id = pane.agentSessionID, !id.isEmpty else { return false }
// transcript が記録済みなのに実在しない = 会話を保存せず終了した空セッション。
// resume しても "No conversation found" になるだけなのでスキップする。
if let path = pane.agentTranscriptPath,
!FileManager.default.fileExists(atPath: path) { return false }
return true
}
if !resumable.isEmpty {
for pane in resumable {
tiling.sendToTerminal(
paneID: pane.id,
command: agentAdapter.launchCommand(resumeID: pane.agentSessionID)
)
}
return
}
}

// 従来フォールバック(タブ別 ID の無い旧データ): セッション単位の ID を 1 本だけ。
let command = agent.launchCommand()
if !tiling.sendToExistingTerminal(command: command) {
tiling.launchInNewTerminal(title: agentAdapter.displayName, command: command)
}
}

var body: some View {
VStack(spacing: 0) {
sessionBar
Expand All @@ -588,10 +658,27 @@ struct SessionDetailView: View {
.padding(.leading, sidebarCollapsed ? 0 : 10)
.ignoresSafeArea(.container, edges: .top)
.navigationTitle(session.name)
.onAppear { if isActive { work.start() } }
.onAppear {
// hooks イベントの(ペイン, セッション ID)対応をタブへ記録する配線。
// 背景セッションでも claude は動き続けるので、isActive に関係なく張る。
// SessionDetailView は ZStack に常駐するため実質セッションごとに 1 回。
let tiling = tiling
session.agent?.onPaneSessionID = { sessionID, paneUUIDString, transcriptPath in
tiling.recordAgentSession(
id: sessionID, paneUUIDString: paneUUIDString, transcriptPath: transcriptPath
)
}
// アプリ終了時の最終レイアウト保存用(onDisappear は終了時に呼ばれる保証がない)。
store.registerLiveLayout(session.id) { [weak tiling] in tiling?.snapshot() }
if isActive {
work.start()
triggerAutoResumeIfNeeded()
}
}
.onChange(of: isActive) { _, active in
if active {
work.start()
triggerAutoResumeIfNeeded()
} else {
work.stop()
}
Expand Down
5 changes: 5 additions & 0 deletions app/Sources/FileTree.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ struct FileTreeRow: View {
var isLast: Bool = true
var lineage: [Bool] = []

@Environment(\.accessibilityReduceMotion) private var reduceMotion

static let rowHeight: CGFloat = 22
static let indentWidth: CGFloat = 14

Expand All @@ -214,6 +216,9 @@ struct FileTreeRow: View {
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(expanded ? 90 : 0))
// 回転だけアニメ。行の出入りは即時のまま(ツリーは高頻度操作なので
// カスケードさせず crisp に保つ)。
.animation(reduceMotion ? nil : LaboTheme.Motion.move, value: expanded)
} else {
Color.clear
}
Expand Down
16 changes: 15 additions & 1 deletion app/Sources/HookForwarder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import Foundation
/// AF_UNIX ソケットへ 1 接続で送って即 exit する。Claude を待たせないよう速やかに終了。
enum HookForwarder {
static func forward(socketPath: String) {
let input = FileHandle.standardInput.readDataToEndOfFile()
var input = FileHandle.standardInput.readDataToEndOfFile()
input = annotatePane(input)

let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { exit(0) }
Expand All @@ -30,4 +31,17 @@ enum HookForwarder {
}
exit(0)
}

/// hook プロセスは claude(→ そのペインのシェル)の子孫なので、LaboLabo が端末ごとに
/// 仕込んだ LABOLABO_PANE(ペイン UUID)を環境変数として継承している。それをペイロードに
/// `labolabo_pane_id` として足すことで、session_id とタブの対応付けをアプリ側で復元できる。
/// 変数が無い(外部ターミナル等)/ JSON でない場合は原文をそのまま返す。
private static func annotatePane(_ input: Data) -> Data {
guard let paneID = ProcessInfo.processInfo.environment["LABOLABO_PANE"], !paneID.isEmpty,
var object = (try? JSONSerialization.jsonObject(with: input)) as? [String: Any] else {
return input
}
object["labolabo_pane_id"] = paneID
return (try? JSONSerialization.data(withJSONObject: object)) ?? input
}
}
15 changes: 15 additions & 0 deletions app/Sources/LaboTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,21 @@ enum LaboTheme {
static let diffDelBg = rose.opacity(0.10)
}

// MARK: - モーション

extension LaboTheme {
/// モーショントークン。UI アニメーションは 300ms 未満・この 3 種に統一する。
/// (曲線は標準の ease では弱いため、強めのカスタムカーブを使う)
enum Motion {
/// フィードバック(押下の戻り・コピー通知など短い応答の出入り): 150ms の強い ease-out。
static let feedback = Animation.timingCurve(0.23, 1, 0.32, 1, duration: 0.15)
/// 画面内の移動・変形(選択ピルのスライド・シェブロン回転など): 180ms の強い ease-in-out。
static let move = Animation.timingCurve(0.77, 0, 0.175, 1, duration: 0.18)
/// 色・数値のクロスフェード(状態色・カウント変化): 200ms の ease-in-out。
static let tint = Animation.easeInOut(duration: 0.2)
}
}

// MARK: - 補助イニシャライザ

extension Color {
Expand Down
Loading
Loading