Skip to content

Commit 5a68ede

Browse files
wesmclaude
andauthored
fix: resolve project name for deleted nested worktrees (#125)
## Summary When a Claude Code session's working directory was a git worktree that has since been deleted, project name resolution falls back to `filepath.Base(cwd)`. For nested worktrees stored as subdirectories of a project container (e.g. `.superset/worktrees/agentsview/tauri-packaging/`), the base name is just the branch name, so `trimBranchSuffix` can't recover the real project name. ### Superset and Conductor worktree path pattern detection Recognizes the directory layout conventions used by Superset and Conductor worktree managers to extract project names structurally, without needing filesystem or git metadata: - **Superset**: `.superset/worktrees/$PROJECT/$BRANCH[/...]` - **Conductor**: `conductor/workspaces/$PROJECT/$BRANCH[/...]` When a cwd matches one of these patterns, the `$PROJECT` component is extracted directly. This is the primary fix for sessions like `8e5207bb` where the worktree at `.superset/worktrees/agentsview/tauri-packaging` was deleted — it now resolves to `agentsview` instead of `tauri_packaging`. ### Sibling-based git root discovery Adds `repoRootFromSiblings` to `findGitRepoRoot`, which scans sibling directories for linked-worktree `.git` files and follows them to discover the true repo root. Safeguards: - Walks up to the first existing ancestor before scanning (handles deeply nested deleted paths) - Only accepts linked worktrees (`.git/worktrees/`), skips submodules (`.git/modules/`) - Includes `.git` directory children as votes in the unanimity check (supports same-repo layouts where a main checkout sits beside its own worktrees) - Bails out when candidates disagree (mixed-project containers) - Normalizes `gitdir` paths with `filepath.Clean` for Windows compatibility ### Sidebar fixes - Auto-scrolls the sidebar to show the selected session when navigating from the command palette search results - Expands collapsed agent groups when the target session is hidden inside one ### Stale filter data after resync - Refreshes the "All Projects" and agent dropdowns after sync/resync completes by calling `invalidateFilterCaches()` alongside `sessions.load()` Closes #33 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9a1ecd2 commit 5a68ede

File tree

11 files changed

+747
-20
lines changed

11 files changed

+747
-20
lines changed

frontend/src/lib/components/layout/AppHeader.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@
286286
<button
287287
class="header-btn"
288288
class:syncing={sync.syncing}
289-
onclick={() => sync.triggerSync(() => sessions.load())}
289+
onclick={() => sync.triggerSync()}
290290
disabled={sync.syncing}
291291
title="Sync sessions (r)"
292292
aria-label="Sync sessions"

frontend/src/lib/components/modals/ResyncModal.svelte

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
<script lang="ts">
22
import { ui } from "../../stores/ui.svelte.js";
33
import { sync } from "../../stores/sync.svelte.js";
4-
import { sessions } from "../../stores/sessions.svelte.js";
54
65
type View = "confirm" | "progress" | "done" | "error";
76
@@ -12,7 +11,6 @@
1211
const started = sync.triggerResync(
1312
() => {
1413
view = "done";
15-
sessions.load();
1614
},
1715
(err) => {
1816
errorMessage = err.message;

frontend/src/lib/components/sidebar/SessionList.svelte

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,58 @@
212212
});
213213
}
214214
215+
// Scroll to the active session when it changes (e.g. from
216+
// the command palette). Expands collapsed agent groups and
217+
// scrolls the item into view. Only fires on selection
218+
// changes, not on displayItems rebuilds, so collapsing a
219+
// group containing the active session stays collapsed.
220+
let prevRevealedId: string | null = null;
221+
$effect(() => {
222+
const activeId = sessions.activeSessionId;
223+
if (!activeId) {
224+
prevRevealedId = null;
225+
return;
226+
}
227+
if (activeId === prevRevealedId) return;
228+
if (!containerRef) return;
229+
// Read displayItems inside the effect so Svelte tracks
230+
// it — needed to re-run after a group expansion.
231+
const items = displayItems;
232+
const item = items.find(
233+
(it) =>
234+
it.type === "session" &&
235+
it.group?.sessions.some((s) => s.id === activeId),
236+
);
237+
if (!item) {
238+
// Session may be hidden in a collapsed agent group.
239+
// Expand it — the effect will re-run when displayItems
240+
// updates, and prevRevealedId is still unset so the
241+
// second pass will proceed to scroll.
242+
if (!groupByAgent) return;
243+
for (const section of agentSections) {
244+
const owns = section.groups.some((g) =>
245+
g.sessions.some((s) => s.id === activeId),
246+
);
247+
if (owns && collapsedAgents.has(section.agent)) {
248+
toggleAgent(section.agent);
249+
return;
250+
}
251+
}
252+
return;
253+
}
254+
// Item found — mark as revealed so subsequent
255+
// displayItems rebuilds don't re-trigger.
256+
prevRevealedId = activeId;
257+
const itemBottom = item.top + item.height;
258+
const viewTop = containerRef.scrollTop;
259+
const viewBottom = viewTop + containerRef.clientHeight;
260+
if (item.top >= viewTop && itemBottom <= viewBottom) return;
261+
containerRef.scrollTop = Math.max(
262+
0,
263+
item.top - containerRef.clientHeight / 2 + item.height / 2,
264+
);
265+
});
266+
215267
onDestroy(() => {
216268
if (scrollRaf !== null) {
217269
cancelAnimationFrame(scrollRaf);

frontend/src/lib/stores/sessions.svelte.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,3 +646,10 @@ export function buildSessionGroups(sessions: Session[]): SessionGroup[] {
646646
}
647647

648648
export const sessions = createSessionsStore();
649+
650+
// Refresh project/agent dropdowns whenever a sync completes
651+
// (local trigger or detected via status polling).
652+
sync.onSyncComplete(() => {
653+
sessions.invalidateFilterCaches();
654+
sessions.load();
655+
});

frontend/src/lib/stores/sync.svelte.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type {
77
UpdateCheck,
88
} from "../api/types.js";
99

10+
type SyncCompleteListener = () => void;
11+
1012
const POLL_INTERVAL_MS = 10_000;
1113

1214
/**
@@ -49,17 +51,41 @@ class SyncStore {
4951
private lastStatsParams: { include_one_shot?: boolean } =
5052
{};
5153
private statsVersion = 0;
54+
private syncCompleteListeners: SyncCompleteListener[] = [];
55+
private statusHydrated = false;
56+
private pendingHydration = false;
57+
58+
/** Register a callback invoked after any sync completes. */
59+
onSyncComplete(listener: SyncCompleteListener) {
60+
this.syncCompleteListeners.push(listener);
61+
}
62+
63+
private notifySyncComplete() {
64+
for (const fn of this.syncCompleteListeners) {
65+
fn();
66+
}
67+
}
5268

5369
async loadStatus() {
5470
try {
5571
const status = await api.getSyncStatus();
5672
const newLastSync = status.last_sync || null;
73+
const isInitial = !this.statusHydrated;
74+
this.statusHydrated = true;
5775
const changed =
5876
newLastSync !== null && newLastSync !== this.lastSync;
5977
this.lastSync = newLastSync;
6078
this.lastSyncStats = status.stats;
61-
if (changed) this.loadStats();
79+
// Suppress notifications on initial hydration and
80+
// when a local sync just completed (pendingHydration).
81+
if (this.pendingHydration) {
82+
this.pendingHydration = false;
83+
} else if (changed && !isInitial) {
84+
this.loadStats();
85+
this.notifySyncComplete();
86+
}
6287
} catch (error) {
88+
this.pendingHydration = false;
6389
console.warn("Failed to load sync status:", error);
6490
}
6591
}
@@ -163,9 +189,14 @@ class SyncStore {
163189
handle.done
164190
.then((s: SyncStats) => {
165191
this.lastSyncStats = s;
166-
this.lastSync = new Date().toISOString();
167192
this.loadStats();
168193
finalizeSync();
194+
this.notifySyncComplete();
195+
// Hydrate the authoritative server timestamp.
196+
// pendingHydration suppresses the notification so
197+
// the poll path won't double-fire.
198+
this.pendingHydration = true;
199+
this.loadStatus();
169200
onComplete?.();
170201
})
171202
.catch((err: unknown) => {

frontend/src/lib/utils/keyboard.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export function registerShortcuts(
8787
},
8888
o: () => ui.toggleSort(),
8989
l: () => ui.cycleLayout(),
90-
r: () => sync.triggerSync(() => sessions.load()),
90+
r: () => sync.triggerSync(),
9191
e: () => {
9292
if (sessions.activeSessionId) {
9393
window.open(

internal/parser/discovery.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1008,7 +1008,6 @@ func isContainedIn(child, root string) bool {
10081008
!strings.HasPrefix(rel, ".."+string(filepath.Separator))
10091009
}
10101010

1011-
10121011
// DiscoverVSCodeCopilotSessions traverses the VSCode
10131012
// workspaceStorage directory to find chatSessions/*.json
10141013
// and *.jsonl files. When both formats exist for the same

internal/parser/iflow.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,4 +451,4 @@ func isIflowSystemMessage(content string) bool {
451451
}
452452
}
453453
return false
454-
}
454+
}

internal/parser/iflow_parser_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,16 @@ import (
99

1010
func TestParseIflowSession(t *testing.T) {
1111
tests := []struct {
12-
name string
13-
filename string
14-
expectID string
12+
name string
13+
filename string
14+
expectID string
1515
expectMessageCount int
1616
expectFirstMessage string
1717
}{
1818
{
19-
name: "basic iFlow session",
20-
filename: "testdata/iflow/session-5de701fc-7454-4858-a249-95cac4fd3b51.jsonl",
21-
expectID: "iflow:5de701fc-7454-4858-a249-95cac4fd3b51",
19+
name: "basic iFlow session",
20+
filename: "testdata/iflow/session-5de701fc-7454-4858-a249-95cac4fd3b51.jsonl",
21+
expectID: "iflow:5de701fc-7454-4858-a249-95cac4fd3b51",
2222
expectMessageCount: 11,
2323
expectFirstMessage: "启动app时确保环境变量 DOCKER_API_VERSION=\"1.46\"",
2424
},
@@ -437,4 +437,4 @@ func TestIflowSessionIDExtraction(t *testing.T) {
437437
}
438438
})
439439
}
440-
}
440+
}

0 commit comments

Comments
 (0)