Skip to content

Commit 46187d5

Browse files
committed
fix: key workspace by relative path instead of branch name
1 parent 0c51f39 commit 46187d5

45 files changed

Lines changed: 2023 additions & 2086 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/twig/src/main/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ import {
4343
} from "./services/posthog-analytics.js";
4444
import type { TaskLinkService } from "./services/task-link/service";
4545
import type { UpdatesService } from "./services/updates/service.js";
46-
import { migrateStoredWorktreePaths } from "./utils/store.js";
46+
import type { WorkspaceService } from "./services/workspace/service.js";
47+
import { migrateTaskAssociations } from "./utils/store.js";
4748

4849
const __filename = fileURLToPath(import.meta.url);
4950
const __dirname = path.dirname(__filename);
@@ -321,8 +322,7 @@ function createWindow(): void {
321322
}
322323

323324
app.whenReady().then(() => {
324-
// Migrate stored worktree paths from legacy directories (e.g., ~/.array -> ~/.twig)
325-
migrateStoredWorktreePaths();
325+
migrateTaskAssociations();
326326

327327
createWindow();
328328
ensureClaudeConfigDir();
@@ -348,6 +348,12 @@ app.whenReady().then(() => {
348348
// Preload external app icons in background
349349
container.get<ExternalAppsService>(MAIN_TOKENS.ExternalAppsService);
350350

351+
// Initialize workspace branch watcher for live branch rename detection
352+
const workspaceService = container.get<WorkspaceService>(
353+
MAIN_TOKENS.WorkspaceService,
354+
);
355+
workspaceService.initBranchWatcher();
356+
351357
// Handle case where app was launched by a deep link
352358
if (process.platform === "darwin") {
353359
// On macOS, the open-url event may have fired before app was ready

apps/twig/src/main/services/agent/schemas.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@ export const cancelSessionInput = z.object({
9393
export const interruptReasonSchema = z.enum([
9494
"user_request",
9595
"moving_to_worktree",
96-
"moving_to_local",
9796
]);
9897
export type InterruptReason = z.infer<typeof interruptReasonSchema>;
9998

@@ -198,13 +197,24 @@ export const listSessionsInput = z.object({
198197
taskId: z.string(),
199198
});
200199

201-
export const notifyCwdChangeInput = z.object({
200+
export const detachedHeadContext = z.object({
201+
type: z.literal("detached_head"),
202+
branchName: z.string(),
203+
isDetached: z.boolean(),
204+
});
205+
206+
export const sessionContextChangeSchema = detachedHeadContext;
207+
208+
export type SessionContextChange = z.infer<typeof sessionContextChangeSchema>;
209+
210+
export const notifySessionContextInput = z.object({
202211
sessionId: z.string(),
203-
newPath: z.string(),
204-
reason: z.enum(["moving_to_worktree", "moving_to_local"]),
212+
context: sessionContextChangeSchema,
205213
});
206214

207-
export type NotifyCwdChangeInput = z.infer<typeof notifyCwdChangeInput>;
215+
export type NotifySessionContextInput = z.infer<
216+
typeof notifySessionContextInput
217+
>;
208218

209219
export const sessionInfoSchema = z.object({
210220
taskRunId: z.string(),

apps/twig/src/main/services/agent/service.ts

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ interface ManagedSession {
151151
interruptReason?: InterruptReason;
152152
needsRecreation: boolean;
153153
promptPending: boolean;
154-
pendingCwdContext?: string;
154+
pendingContext?: string;
155155
}
156156

157157
function getClaudeCliPath(): string {
@@ -450,7 +450,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
450450

451451
// Preserve state that should survive recreation
452452
const config = existing.config;
453-
const pendingCwdContext = existing.pendingCwdContext;
453+
const pendingContext = existing.pendingContext;
454454

455455
this.cleanupSession(taskRunId);
456456

@@ -460,8 +460,8 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
460460
}
461461

462462
// Restore preserved state
463-
if (pendingCwdContext) {
464-
newSession.pendingCwdContext = pendingCwdContext;
463+
if (pendingContext) {
464+
newSession.pendingContext = pendingContext;
465465
}
466466

467467
return newSession;
@@ -484,15 +484,19 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
484484
session = await this.recreateSession(sessionId);
485485
}
486486

487-
// Prepend pending CWD context if present
487+
// Prepend pending context if present
488488
let finalPrompt = prompt;
489-
if (session.pendingCwdContext) {
490-
log.info("Prepending CWD context to prompt", { sessionId });
489+
if (session.pendingContext) {
490+
log.info("Prepending context to prompt", { sessionId });
491491
finalPrompt = [
492-
{ type: "text", text: `_${session.pendingCwdContext}_\n\n` },
492+
{
493+
type: "text",
494+
text: `_${session.pendingContext}_\n\n`,
495+
_meta: { ui: { hidden: true } },
496+
},
493497
...prompt,
494498
];
495-
session.pendingCwdContext = undefined;
499+
session.pendingContext = undefined;
496500
}
497501

498502
session.lastActivityAt = Date.now();
@@ -652,25 +656,21 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
652656
}
653657

654658
/**
655-
* Notify a session that its working directory context has changed.
659+
* Notify a session of a context change (CWD moved, detached HEAD, etc).
656660
* Used when focusing/unfocusing worktrees - the agent doesn't need to respawn
657661
* because it has additionalDirectories configured, but it should know about the change.
658662
*/
659-
async notifyCwdChange(
663+
async notifySessionContext(
660664
sessionId: string,
661-
newPath: string,
662-
reason: "moving_to_worktree" | "moving_to_local",
665+
context: import("./schemas.js").SessionContextChange,
663666
): Promise<void> {
664667
const session = this.sessions.get(sessionId);
665668
if (!session) {
666-
log.warn("Session not found for CWD notification", { sessionId });
669+
log.warn("Session not found for context notification", { sessionId });
667670
return;
668671
}
669672

670-
const contextMessage =
671-
reason === "moving_to_worktree"
672-
? `Your working directory has been moved to a worktree at ${newPath} because the user focused on another task.`
673-
: `Your working directory has been returned to the main repository at ${newPath}.`;
673+
const contextMessage = this.buildContextMessage(context);
674674

675675
// Check if session is currently busy
676676
if (session.promptPending) {
@@ -679,21 +679,35 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
679679
{
680680
type: "text",
681681
text: `${contextMessage} Continue where you left off.`,
682+
_meta: { ui: { hidden: true } },
682683
},
683684
]);
684685
} else {
685686
// Idle session: store for prepending to next user message
686-
session.pendingCwdContext = contextMessage;
687+
session.pendingContext = contextMessage;
687688
}
688689

689-
log.info("Notified session of CWD change", {
690+
log.info("Notified session of context change", {
690691
sessionId,
691-
newPath,
692-
reason,
692+
context,
693693
wasPromptPending: session.promptPending,
694694
});
695695
}
696696

697+
private buildContextMessage(
698+
context: import("./schemas.js").SessionContextChange,
699+
): string {
700+
if (context.isDetached) {
701+
return `Your worktree is now on detached HEAD while the user edits in their main repo. The branch is \`${context.branchName}\`.
702+
703+
For git operations while detached:
704+
- Commit: works normally
705+
- Push: \`git push origin HEAD:refs/heads/${context.branchName}\`
706+
- Pull: \`git fetch origin ${context.branchName} && git merge FETCH_HEAD\``;
707+
}
708+
return `Your worktree is back on branch \`${context.branchName}\`. Normal git commands work again.`;
709+
}
710+
697711
async cleanupAll(): Promise<void> {
698712
log.info("Cleaning up all agent sessions", {
699713
sessionCount: this.sessions.size,

apps/twig/src/main/services/file-watcher/service.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,10 @@ export class FileWatcherService extends TypedEventEmitter<FileWatcherEvents> {
217217
return;
218218
}
219219
const isRelevant = events.some(
220-
(e) => e.path.endsWith("/HEAD") || e.path.endsWith("/index"),
220+
(e) =>
221+
e.path.endsWith("/HEAD") ||
222+
e.path.endsWith("/index") ||
223+
e.path.includes("/refs/heads/"),
221224
);
222225
if (isRelevant) {
223226
this.emit(FileWatcherEvent.GitStateChanged, { repoPath });

apps/twig/src/main/services/focus/schemas.ts

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,16 @@ export const stashResultSchema = focusResultSchema.extend({
1414

1515
export type StashResult = z.infer<typeof stashResultSchema>;
1616

17-
export const focusRefDataSchema = z.object({
18-
status: z.enum(["focusing", "focused", "unfocusing"]),
17+
export const focusSessionSchema = z.object({
18+
mainRepoPath: z.string(),
19+
worktreePath: z.string(),
20+
branch: z.string(),
1921
originalBranch: z.string(),
20-
targetBranch: z.string(),
2122
mainStashRef: z.string().nullable(),
22-
localWorktreePath: z.string().nullable(),
23+
commitSha: z.string(),
2324
});
2425

25-
export type FocusRefData = z.infer<typeof focusRefDataSchema>;
26-
27-
export interface FocusSession {
28-
mainRepoPath: string;
29-
worktreePath: string;
30-
branch: string;
31-
originalBranch: string;
32-
mainStashRef: string | null;
33-
localWorktreePath: string | null;
34-
}
26+
export type FocusSession = z.infer<typeof focusSessionSchema>;
3527

3628
export const repoPathInput = z.object({ repoPath: z.string() });
3729
export const mainRepoPathInput = z.object({ mainRepoPath: z.string() });
@@ -48,10 +40,6 @@ export const reattachInput = z.object({
4840
worktreePath: z.string(),
4941
branch: z.string(),
5042
});
51-
export const writeRefInput = z.object({
52-
mainRepoPath: z.string(),
53-
data: focusRefDataSchema,
54-
});
5543
export const syncInput = z.object({
5644
mainRepoPath: z.string(),
5745
worktreePath: z.string(),
@@ -60,8 +48,3 @@ export const findWorktreeInput = z.object({
6048
mainRepoPath: z.string(),
6149
branch: z.string(),
6250
});
63-
64-
export const getCurrentStateOutput = z.object({
65-
refData: focusRefDataSchema.nullable(),
66-
currentBranch: z.string().nullable(),
67-
});

0 commit comments

Comments
 (0)