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: 5 additions & 2 deletions frontend/e2e/activity-minimap.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,15 @@ test.describe("Activity Minimap", () => {
const lastBar = clickableBars.nth(barCount - 1);
await lastBar.click();

if (barCount > 1) {
const isScrollable = await sp.scroller.evaluate(
(el) => el.scrollHeight > el.clientHeight,
);
if (barCount > 1 && isScrollable) {
await expect
.poll(
() =>
sp.scroller.evaluate((el) => el.scrollTop),
{ timeout: 3000 },
{ timeout: 5000 },
)
.not.toBe(scrollBefore);
}
Expand Down
10 changes: 7 additions & 3 deletions frontend/src/lib/components/layout/SessionBreadcrumb.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import {
supportsResume,
buildResumeCommand,
formatResumeResponseCommand,
} from "../../utils/resume.js";

import { inSessionSearch } from "../../stores/inSessionSearch.svelte.js";
Expand Down Expand Up @@ -195,7 +196,8 @@
}
// Launch failed — fall back to clipboard copy.
if (resp.command) {
const ok = await copyToClipboard(resp.command);
const cmd = formatResumeResponseCommand(session.agent, resp);
const ok = cmd ? await copyToClipboard(cmd) : false;
showFeedback(ok ? "Command copied!" : "Failed");
return;
}
Expand All @@ -217,7 +219,8 @@
try {
const resp = await resumeSession(session.id, { command_only: true });
if (resp.command) {
const ok = await copyToClipboard(resp.command);
const cmd = formatResumeResponseCommand(session.agent, resp);
const ok = cmd ? await copyToClipboard(cmd) : false;
showFeedback(ok ? "Command copied!" : "Failed");
return;
}
Expand Down Expand Up @@ -266,7 +269,8 @@
return;
}
if (resp.command) {
const ok = await copyToClipboard(resp.command);
const cmd = formatResumeResponseCommand(session.agent, resp);
const ok = cmd ? await copyToClipboard(cmd) : false;
showFeedback(ok ? "Command copied!" : "Failed");
return;
}
Expand Down
14 changes: 10 additions & 4 deletions frontend/src/lib/utils/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import {
getExportUrl,
resumeSession,
} from "../api/client.js";
import { supportsResume, buildResumeCommand } from "./resume.js";
import {
supportsResume,
buildResumeCommand,
formatResumeResponseCommand,
} from "./resume.js";
import { copyToClipboard } from "./clipboard.js";

function isInputFocused(): boolean {
Expand Down Expand Up @@ -179,10 +183,12 @@ export function registerShortcuts(
c: () => {
const session = sessions.activeSession;
if (session && supportsResume(session.agent)) {
// Copy resume command to clipboard. Use backend-built command
// (includes cd to project dir) with local fallback.
// Copy a runnable resume command. Cursor needs the backend cwd
// applied client-side so the copied command is self-contained.
resumeSession(session.id, { command_only: true }).then((resp) => {
const cmd = resp.command || buildResumeCommand(
const cmd = formatResumeResponseCommand(
session.agent, resp,
) || buildResumeCommand(
session.agent,
session.id,
);
Expand Down
59 changes: 57 additions & 2 deletions frontend/src/lib/utils/resume.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import {
buildResumeCommand,
formatResumeResponseCommand,
supportsResume,
} from "./resume.js";

Expand All @@ -9,13 +10,13 @@ describe("supportsResume", () => {
expect(supportsResume("claude")).toBe(true);
expect(supportsResume("codex")).toBe(true);
expect(supportsResume("copilot")).toBe(true);
expect(supportsResume("cursor")).toBe(true);
expect(supportsResume("gemini")).toBe(true);
expect(supportsResume("opencode")).toBe(true);
expect(supportsResume("amp")).toBe(true);
});

it("returns false for unsupported agents", () => {
expect(supportsResume("cursor")).toBe(false);
expect(supportsResume("vscode-copilot")).toBe(false);
expect(supportsResume("unknown")).toBe(false);
});
Expand Down Expand Up @@ -46,6 +47,12 @@ describe("buildResumeCommand", () => {
).toBe("gemini --resume sess-2");
});

it("returns null for cursor (server-only resume)", () => {
expect(
buildResumeCommand("cursor", "cursor:chat-7"),
).toBeNull();
});

it("generates opencode resume command", () => {
expect(
buildResumeCommand("opencode", "opencode:s3"),
Expand All @@ -71,7 +78,6 @@ describe("buildResumeCommand", () => {
});

it("returns null for unsupported agents", () => {
expect(buildResumeCommand("cursor", "id")).toBeNull();
expect(buildResumeCommand("unknown", "id")).toBeNull();
});

Expand Down Expand Up @@ -171,3 +177,52 @@ describe("buildResumeCommand", () => {
expect(cmd).toBe("amp --resume '$HOME/evil'");
});
});

describe("formatResumeResponseCommand", () => {
it("keeps non-cursor backend commands unchanged", () => {
expect(
formatResumeResponseCommand("claude", {
command: "claude --resume sess-1",
cwd: "/tmp/project",
}),
).toBe("claude --resume sess-1");
});

it("prepends cwd for cursor clipboard copy", () => {
expect(
formatResumeResponseCommand("cursor", {
command: "cursor agent --resume chat-7 --workspace '/tmp/project'",
cwd: "/tmp/project/frontend",
}),
).toBe(
"cd '/tmp/project/frontend' && " +
"cursor agent --resume chat-7 --workspace '/tmp/project'",
);
});

it("quotes cursor cwd when needed", () => {
expect(
formatResumeResponseCommand("cursor", {
command: "cursor agent --resume chat-7 --workspace '/tmp/project dir'",
cwd: "/tmp/project dir/frontend",
}),
).toBe(
"cd '/tmp/project dir/frontend' && " +
"cursor agent --resume chat-7 --workspace '/tmp/project dir'",
);
});

it("returns bare cursor command when cwd is unavailable", () => {
expect(
formatResumeResponseCommand("cursor", {
command: "cursor agent --resume chat-7",
}),
).toBe("cursor agent --resume chat-7");
});

it("returns null for missing backend command", () => {
expect(
formatResumeResponseCommand("cursor", null),
).toBeNull();
});
});
40 changes: 39 additions & 1 deletion frontend/src/lib/utils/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,36 @@ RESUME_AGENTS["codex"] = (id) =>
`codex resume ${shellQuote(id)}`;
RESUME_AGENTS["copilot"] = (id) =>
`copilot --resume=${shellQuote(id)}`;
RESUME_AGENTS["cursor"] = (id) =>
`cursor agent --resume ${shellQuote(id)}`;
RESUME_AGENTS["gemini"] = (id) =>
`gemini --resume ${shellQuote(id)}`;
RESUME_AGENTS["opencode"] = (id) =>
`opencode --session ${shellQuote(id)}`;
RESUME_AGENTS["amp"] = (id) =>
`amp --resume ${shellQuote(id)}`;

/**
* Agents whose resume commands require server-resolved parameters
* (e.g. --workspace, cwd) that the client cannot compute locally.
* buildResumeCommand returns null for these agents so callers
* don't produce incomplete fallback commands.
*/
const SERVER_ONLY_RESUME = new Set(["cursor"]);

/** Flags available for Claude Code resume. */
export interface ClaudeResumeFlags {
skipPermissions?: boolean;
forkSession?: boolean;
print?: boolean;
}

/** Minimal shape of a backend resume response used for clipboard copy. */
export interface ResumeCommandResponse {
command: string;
cwd?: string;
}

/**
* POSIX-safe shell quoting using single quotes.
* Any embedded single quotes are escaped as '"'"'.
Expand All @@ -33,6 +49,11 @@ function shellQuote(s: string): string {
return "'" + s.replace(/'/g, "'\"'\"'") + "'";
}

function commandWithCwd(cmd: string, cwd?: string): string {
if (!cwd) return cmd;
return `cd ${shellQuote(cwd)} && ${cmd}`;
}

/**
* Strip the agent-type prefix from a compound session ID, but only
* when the prefix matches a known agent. Raw IDs that happen to
Expand All @@ -56,7 +77,7 @@ export function supportsResume(agent: string): boolean {
/**
* Build a CLI command to resume the given session in a terminal.
*
* @param agent - The agent type (e.g. "claude", "codex", "gemini")
* @param agent - The agent type (e.g. "claude", "codex", "cursor")
* @param sessionId - The session ID (may include agent prefix)
* @param flags - Optional Claude-specific resume flags
* @returns The shell command string, or null if the agent is not supported
Expand All @@ -66,6 +87,7 @@ export function buildResumeCommand(
sessionId: string,
flags?: ClaudeResumeFlags,
): string | null {
if (SERVER_ONLY_RESUME.has(agent)) return null;
const builder = RESUME_AGENTS[agent];
if (!builder) return null;

Expand All @@ -81,3 +103,19 @@ export function buildResumeCommand(

return cmd;
}

/**
* Format a backend-built resume response for clipboard copy.
*
* Cursor keeps `command` and `cwd` separate in the API so callers can
* choose whether to apply the cwd directly. Clipboard copy needs a
* runnable one-liner, so rebuild it here only for Cursor.
*/
export function formatResumeResponseCommand(
agent: string,
response: ResumeCommandResponse | null | undefined,
): string | null {
if (!response?.command) return null;
if (agent !== "cursor") return response.command;
return commandWithCwd(response.command, response.cwd);
}
36 changes: 36 additions & 0 deletions internal/parser/cursor_paths.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package parser

import (
"path/filepath"
"strings"
)

// ParseCursorTranscriptRelPath validates a path relative to a
// Cursor projects dir and returns the encoded project directory
// name for recognized transcript layouts.
func ParseCursorTranscriptRelPath(rel string) (string, bool) {
rel = filepath.Clean(rel)
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) < 3 || parts[1] != "agent-transcripts" {
return "", false
}

switch len(parts) {
case 3:
if !IsCursorTranscriptExt(parts[2]) {
return "", false
}
return parts[0], true
case 4:
if !IsCursorTranscriptExt(parts[3]) {
return "", false
}
stem := strings.TrimSuffix(parts[3], filepath.Ext(parts[3]))
if stem != parts[2] {
return "", false
}
return parts[0], true
default:
return "", false
}
}
69 changes: 69 additions & 0 deletions internal/parser/discovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1295,6 +1295,75 @@ func TestDiscoverCursorSessions_DedupPrefersJsonl(t *testing.T) {
}
}

func TestParseCursorTranscriptRelPath(t *testing.T) {
tests := []struct {
name string
rel string
wantProject string
wantOK bool
}{
{
name: "flat txt",
rel: filepath.Join("proj-dir", "agent-transcripts", "sess.txt"),
wantProject: "proj-dir",
wantOK: true,
},
{
name: "flat jsonl",
rel: filepath.Join("proj-dir", "agent-transcripts", "sess.jsonl"),
wantProject: "proj-dir",
wantOK: true,
},
{
name: "nested jsonl",
rel: filepath.Join("proj-dir", "agent-transcripts", "sess", "sess.jsonl"),
wantProject: "proj-dir",
wantOK: true,
},
{
name: "nested txt",
rel: filepath.Join("proj-dir", "agent-transcripts", "sess", "sess.txt"),
wantProject: "proj-dir",
wantOK: true,
},
{
name: "nested mismatched filename",
rel: filepath.Join("proj-dir", "agent-transcripts", "sess", "other.jsonl"),
wantOK: false,
},
{
name: "nested auxiliary file",
rel: filepath.Join("proj-dir", "agent-transcripts", "sess", "notes.txt"),
wantOK: false,
},
{
name: "subagent file ignored",
rel: filepath.Join("proj-dir", "agent-transcripts", "sess", "subagents", "child.jsonl"),
wantOK: false,
},
{
name: "wrong extension",
rel: filepath.Join("proj-dir", "agent-transcripts", "sess.json"),
wantOK: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotProject, gotOK := ParseCursorTranscriptRelPath(tt.rel)
if gotOK != tt.wantOK {
t.Fatalf("ok = %v, want %v", gotOK, tt.wantOK)
}
if gotProject != tt.wantProject {
t.Errorf(
"project = %q, want %q",
gotProject, tt.wantProject,
)
}
})
}
}

func TestFindCursorSourceFile(t *testing.T) {
cursorTranscripts := filepath.Join(
"proj-dir", "agent-transcripts",
Expand Down
Loading