Skip to content

Commit f33f74e

Browse files
will-osborneclaude
andcommitted
feat: file history panel with per-run diff viewer
- Backend: track before/after file content for each write tool call, grouped by prompt run. New RunHistory type stores records in-memory per session. GET /api/sessions/{id}/run-history endpoint. - Frontend: RunHistoryView sheet (accessible from inspector header) shows a run timeline on the left, file list + LCS-based diff viewer on the right. Color-coded +/- lines with line numbers and context. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d747540 commit f33f74e

9 files changed

Lines changed: 666 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import Foundation
2+
3+
struct RunRecord: Codable, Identifiable {
4+
let id: String
5+
let prompt: String
6+
let startedAt: Date
7+
let completedAt: Date?
8+
let files: [FileChange]
9+
}
10+
11+
struct FileChange: Codable, Identifiable {
12+
let path: String
13+
let relativePath: String
14+
let before: String
15+
let after: String
16+
17+
var id: String { path }
18+
}

desktop/Orbitor/Orbitor/Networking/APIClient.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,14 @@ final class APIClient: Sendable {
125125
let resp = try JSONDecoder().decode(Resp.self, from: data)
126126
return resp.suggestions
127127
}
128+
129+
/// Returns the per-run file change history for a session.
130+
func sessionRunHistory(id: String) async throws -> [RunRecord] {
131+
let url = baseURL.appendingPathComponent("api/sessions/\(id)/run-history")
132+
let (data, _) = try await session.data(from: url)
133+
struct Resp: Decodable { let runs: [RunRecord] }
134+
return try decoder.decode(Resp.self, from: data).runs
135+
}
128136
}
129137

130138
struct BrowseEntry: Codable, Identifiable {

desktop/Orbitor/Orbitor/Views/Inspector/InspectorView.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ struct InspectorView: View {
44
@Environment(AppState.self) private var appState
55
@Environment(\.theme) private var theme
66
@State private var gitBranch: String? = nil
7+
@State private var showHistory = false
78

89
var body: some View {
910
if let session = appState.sessionList.selectedSession {
@@ -20,6 +21,15 @@ struct InspectorView: View {
2021
.font(.caption)
2122
.foregroundStyle(theme.red)
2223
}
24+
Button {
25+
showHistory = true
26+
} label: {
27+
Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
28+
.font(.caption)
29+
.foregroundStyle(theme.muted)
30+
}
31+
.buttonStyle(.plain)
32+
.help("File History")
2333
}
2434

2535
// Session info grid
@@ -187,6 +197,11 @@ struct InspectorView: View {
187197
.background(theme.panel)
188198
.onAppear { loadGitBranch(for: session) }
189199
.onChange(of: session.id) { _, _ in loadGitBranch(for: session) }
200+
.sheet(isPresented: $showHistory) {
201+
RunHistoryView(sessionID: session.id)
202+
.environment(appState)
203+
.environment(\.theme, theme)
204+
}
190205
} else {
191206
VStack {
192207
Text("No session")
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
import SwiftUI
2+
3+
struct RunHistoryView: View {
4+
let sessionID: String
5+
@Environment(AppState.self) private var appState
6+
@Environment(\.theme) private var theme
7+
@Environment(\.dismiss) private var dismiss
8+
9+
@State private var runs: [RunRecord] = []
10+
@State private var isLoading = false
11+
@State private var selectedRunID: String?
12+
@State private var selectedFile: FileChange?
13+
14+
private var selectedRun: RunRecord? {
15+
runs.first { $0.id == selectedRunID }
16+
}
17+
18+
var body: some View {
19+
VStack(spacing: 0) {
20+
// Title bar
21+
HStack {
22+
Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
23+
.foregroundStyle(theme.accent)
24+
Text("File History")
25+
.font(.headline)
26+
.foregroundStyle(theme.text)
27+
Spacer()
28+
if isLoading {
29+
ProgressView().controlSize(.small)
30+
}
31+
Button {
32+
dismiss()
33+
} label: {
34+
Image(systemName: "xmark.circle.fill")
35+
.foregroundStyle(theme.muted)
36+
}
37+
.buttonStyle(.plain)
38+
}
39+
.padding(.horizontal, 16)
40+
.padding(.vertical, 12)
41+
.background(theme.panel)
42+
43+
Divider().background(theme.sep)
44+
45+
if runs.isEmpty && !isLoading {
46+
emptyState
47+
} else {
48+
HSplitView {
49+
// Left: run list
50+
runList
51+
.frame(minWidth: 200, idealWidth: 240, maxWidth: 300)
52+
53+
// Right: file list + diff
54+
rightPanel
55+
.frame(minWidth: 400)
56+
}
57+
}
58+
}
59+
.background(theme.bg)
60+
.frame(minWidth: 800, minHeight: 500)
61+
.task { await load() }
62+
}
63+
64+
// MARK: - Subviews
65+
66+
private var emptyState: some View {
67+
VStack(spacing: 12) {
68+
Image(systemName: "clock.badge.xmark")
69+
.font(.system(size: 36))
70+
.foregroundStyle(theme.muted)
71+
Text("No file changes recorded yet")
72+
.font(.headline)
73+
.foregroundStyle(theme.muted)
74+
Text("File changes will appear here after the agent writes files.")
75+
.font(.caption)
76+
.foregroundStyle(theme.muted.opacity(0.7))
77+
.multilineTextAlignment(.center)
78+
}
79+
.frame(maxWidth: .infinity, maxHeight: .infinity)
80+
}
81+
82+
private var runList: some View {
83+
VStack(spacing: 0) {
84+
Text("RUNS")
85+
.font(.caption2.bold())
86+
.foregroundStyle(theme.muted)
87+
.frame(maxWidth: .infinity, alignment: .leading)
88+
.padding(.horizontal, 12)
89+
.padding(.vertical, 8)
90+
91+
Divider().background(theme.sep)
92+
93+
ScrollView {
94+
LazyVStack(spacing: 0) {
95+
ForEach(runs) { run in
96+
RunRowView(run: run, isSelected: run.id == selectedRunID)
97+
.contentShape(Rectangle())
98+
.onTapGesture {
99+
selectedRunID = run.id
100+
selectedFile = run.files.first
101+
}
102+
}
103+
}
104+
}
105+
}
106+
.background(theme.panel)
107+
}
108+
109+
private var rightPanel: some View {
110+
Group {
111+
if let run = selectedRun {
112+
VSplitView {
113+
// File list
114+
fileList(for: run)
115+
.frame(minHeight: 80, idealHeight: 120, maxHeight: 200)
116+
117+
// Diff viewer
118+
diffPanel
119+
}
120+
} else {
121+
VStack {
122+
Text("Select a run to view changes")
123+
.foregroundStyle(theme.muted)
124+
}
125+
.frame(maxWidth: .infinity, maxHeight: .infinity)
126+
}
127+
}
128+
}
129+
130+
private func fileList(for run: RunRecord) -> some View {
131+
VStack(spacing: 0) {
132+
HStack {
133+
Text("FILES CHANGED")
134+
.font(.caption2.bold())
135+
.foregroundStyle(theme.muted)
136+
Spacer()
137+
Text("\(run.files.count) file\(run.files.count == 1 ? "" : "s")")
138+
.font(.caption2)
139+
.foregroundStyle(theme.muted)
140+
}
141+
.padding(.horizontal, 12)
142+
.padding(.vertical, 8)
143+
144+
Divider().background(theme.sep)
145+
146+
ScrollView {
147+
LazyVStack(spacing: 0) {
148+
ForEach(run.files) { file in
149+
FileRowView(file: file, isSelected: file.id == selectedFile?.id)
150+
.contentShape(Rectangle())
151+
.onTapGesture { selectedFile = file }
152+
}
153+
}
154+
}
155+
}
156+
.background(theme.panel)
157+
}
158+
159+
private var diffPanel: some View {
160+
VStack(spacing: 0) {
161+
if let file = selectedFile {
162+
HStack(spacing: 8) {
163+
Image(systemName: file.before.isEmpty ? "plus.circle.fill" : "pencil.circle.fill")
164+
.font(.caption)
165+
.foregroundStyle(file.before.isEmpty ? Color.green : theme.cyan)
166+
Text(file.relativePath)
167+
.font(.system(.caption, design: .monospaced))
168+
.foregroundStyle(theme.text)
169+
Spacer()
170+
let added = countAdded(file)
171+
let removed = countRemoved(file)
172+
if added > 0 {
173+
Text("+\(added)")
174+
.font(.caption2.monospacedDigit())
175+
.foregroundStyle(Color.green)
176+
}
177+
if removed > 0 {
178+
Text("-\(removed)")
179+
.font(.caption2.monospacedDigit())
180+
.foregroundStyle(Color.red)
181+
}
182+
}
183+
.padding(.horizontal, 12)
184+
.padding(.vertical, 8)
185+
186+
Divider().background(theme.sep)
187+
188+
FileDiffView(before: file.before, after: file.after)
189+
} else {
190+
VStack {
191+
Text("Select a file to view diff")
192+
.foregroundStyle(theme.muted)
193+
}
194+
.frame(maxWidth: .infinity, maxHeight: .infinity)
195+
}
196+
}
197+
}
198+
199+
// MARK: - Helpers
200+
201+
private func load() async {
202+
isLoading = true
203+
defer { isLoading = false }
204+
if let records = try? await appState.api.sessionRunHistory(id: sessionID) {
205+
runs = records
206+
selectedRunID = runs.first?.id
207+
selectedFile = runs.first?.files.first
208+
}
209+
}
210+
211+
private func countAdded(_ file: FileChange) -> Int {
212+
computeDiff(before: file.before, after: file.after).filter { $0.kind == .added }.count
213+
}
214+
215+
private func countRemoved(_ file: FileChange) -> Int {
216+
computeDiff(before: file.before, after: file.after).filter { $0.kind == .removed }.count
217+
}
218+
}
219+
220+
// MARK: - Row views
221+
222+
private struct RunRowView: View {
223+
let run: RunRecord
224+
let isSelected: Bool
225+
@Environment(\.theme) private var theme
226+
227+
var body: some View {
228+
VStack(alignment: .leading, spacing: 3) {
229+
HStack(spacing: 6) {
230+
Circle()
231+
.fill(isSelected ? theme.accent : theme.muted.opacity(0.5))
232+
.frame(width: 6, height: 6)
233+
Text(run.startedAt, style: .time)
234+
.font(.caption2)
235+
.foregroundStyle(theme.muted)
236+
Spacer()
237+
Text("\(run.files.count) file\(run.files.count == 1 ? "" : "s")")
238+
.font(.caption2)
239+
.foregroundStyle(theme.muted)
240+
}
241+
242+
Text(run.prompt.isEmpty ? "(continuation)" : run.prompt)
243+
.font(.caption)
244+
.foregroundStyle(theme.text)
245+
.lineLimit(2)
246+
.padding(.leading, 12)
247+
248+
if let dur = runDuration {
249+
Text(dur)
250+
.font(.caption2)
251+
.foregroundStyle(theme.muted.opacity(0.7))
252+
.padding(.leading, 12)
253+
}
254+
}
255+
.padding(.horizontal, 12)
256+
.padding(.vertical, 8)
257+
.background(isSelected ? theme.accent.opacity(0.12) : Color.clear)
258+
.overlay(alignment: .leading) {
259+
if isSelected {
260+
Rectangle()
261+
.fill(theme.accent)
262+
.frame(width: 2)
263+
}
264+
}
265+
}
266+
267+
private var runDuration: String? {
268+
guard let end = run.completedAt else { return nil }
269+
let secs = Int(end.timeIntervalSince(run.startedAt))
270+
if secs < 60 { return "\(secs)s" }
271+
return "\(secs / 60)m \(secs % 60)s"
272+
}
273+
}
274+
275+
private struct FileRowView: View {
276+
let file: FileChange
277+
let isSelected: Bool
278+
@Environment(\.theme) private var theme
279+
280+
var body: some View {
281+
HStack(spacing: 8) {
282+
Image(systemName: file.before.isEmpty ? "plus.circle.fill" : "pencil.circle.fill")
283+
.font(.system(size: 9))
284+
.foregroundStyle(file.before.isEmpty ? Color.green : theme.cyan)
285+
Text(file.relativePath)
286+
.font(.system(size: 11, design: .monospaced))
287+
.foregroundStyle(theme.text)
288+
.lineLimit(1)
289+
.truncationMode(.head)
290+
Spacer()
291+
}
292+
.padding(.horizontal, 12)
293+
.padding(.vertical, 5)
294+
.background(isSelected ? theme.accent.opacity(0.12) : Color.clear)
295+
}
296+
}

0 commit comments

Comments
 (0)