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
4 changes: 4 additions & 0 deletions apps/server/src/terminal/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,7 @@ it.layer(
threadId: "thread-1",
terminalId: DEFAULT_TERMINAL_ID,
afterSequence: initialSequence,
afterEpoch: initialSnapshot.snapshot.attachEpoch,
},
(event) => Ref.update(resumedEvents, (events) => [...events, event]),
);
Expand Down Expand Up @@ -775,6 +776,7 @@ it.layer(
threadId: "thread-1",
terminalId: DEFAULT_TERMINAL_ID,
afterSequence: opened.sequence,
afterEpoch: opened.attachEpoch,
},
(event) => Ref.update(attachEvents, (events) => [...events, event]),
);
Expand All @@ -799,6 +801,7 @@ it.layer(
threadId: "thread-1",
terminalId: DEFAULT_TERMINAL_ID,
afterSequence: opened.sequence + 1,
afterEpoch: opened.attachEpoch,
},
(event) => Ref.update(attachEvents, (events) => [...events, event]),
);
Expand Down Expand Up @@ -835,6 +838,7 @@ it.layer(
threadId: "thread-1",
terminalId: DEFAULT_TERMINAL_ID,
afterSequence: opened.sequence,
afterEpoch: opened.attachEpoch,
},
(event) => Ref.update(attachEvents, (events) => [...events, event]),
);
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/terminal/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,8 @@ interface TerminalSessionState {
exitSignal: number | null;
updatedAt: string;
eventSequence: number;
/** Coder: a new value per shell session, because each session restarts its sequences. */
attachEpoch: string;
inputCount: number;
cols: number;
rows: number;
Expand Down Expand Up @@ -346,6 +348,7 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot {
label: terminalWireLabel(session),
updatedAt: session.updatedAt,
sequence: session.eventSequence,
attachEpoch: session.attachEpoch,
};
}

Expand Down Expand Up @@ -2660,6 +2663,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
exitSignal: null,
updatedAt: yield* nowIso,
eventSequence: 0,
attachEpoch: crypto.randomUUID(),
inputCount: 0,
cols,
rows,
Expand Down Expand Up @@ -2863,6 +2867,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
const afterSequence = input.afterSequence;
const snapshotSequence = initialSnapshot.sequence;
if (afterSequence === undefined || snapshotSequence === undefined) return null;
// A cursor from another shell session numbers different events.
if (input.afterEpoch === undefined || input.afterEpoch !== initialSnapshot.attachEpoch) {
return null;
}
if (afterSequence > snapshotSequence) return null;
if (afterSequence === snapshotSequence) return [];
const replay = attachReplay.get(toSessionKey(input.threadId, input.terminalId));
Expand Down Expand Up @@ -3147,6 +3155,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
exitSignal: null,
updatedAt: yield* nowIso,
eventSequence: 0,
attachEpoch: crypto.randomUUID(),
inputCount: 0,
cols,
rows,
Expand Down
18 changes: 16 additions & 2 deletions apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,17 @@ export default function GitActionsControl({
const changesTotals = gitStatusForActions?.branchChanges ?? gitStatusForActions?.workingTree;

const allFiles = gitStatusForActions?.workingTree.files ?? [];
// Coder: status paths are repository-relative, and the Files surface opens paths relative to
// the thread's folder, which can be a subfolder of the repository.
const changedFileOpenPath = (filePath: string): string | null => {
const rootPath = gitStatusForActions?.workingTree.rootPath;
if (!rootPath || !gitCwd) return filePath;
const root = rootPath.replace(/\/+$/, "");
const base = gitCwd.replace(/\/+$/, "");
if (!base.startsWith(`${root}/`)) return filePath;
const prefix = `${base.slice(root.length + 1)}/`;
return filePath.startsWith(prefix) ? filePath.slice(prefix.length) : null;
};
const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path));
const allSelected = excludedFiles.size === 0;
const noneSelected = selectedFiles.length === 0;
Expand Down Expand Up @@ -1903,8 +1914,11 @@ export default function GitActionsControl({
<button
type="button"
className="flex min-w-0 flex-1 items-center justify-between gap-3 text-left"
onClick={() => onOpenFile?.(file.path)}
disabled={!onOpenFile}
onClick={() => {
const openPath = changedFileOpenPath(file.path);
if (openPath !== null) onOpenFile?.(openPath);
}}
disabled={!onOpenFile || changedFileOpenPath(file.path) === null}
>
<StartTruncatedPath
path={file.path}
Expand Down
15 changes: 10 additions & 5 deletions docs/internals/coder-only.md
Original file line number Diff line number Diff line change
Expand Up @@ -764,7 +764,8 @@ listed here is drift to remove rather than fork behavior to keep.
- `ModelCapabilities` carries the provider-reported `supportedRuntimeModes`; custom models use
`CustomModelCapabilities`, which cannot declare them.
- Tool items keep legacy screenshot `artifacts`; rate-limit events carry the raw payload.
- Terminal attach input adds `afterSequence`, and the attach stream adds a `resumed` event.
- Terminal attach input adds `afterSequence` and `afterEpoch`, terminal snapshots add
`attachEpoch`, and the attach stream adds a `resumed` event.
- Claude launch args reach the workspace CLI except the stream-json transport flags. `ClaudeAgentSdk.ts` passes
`mcpServers` and `strictMcpConfig` as the SDK does and rejects in-process (`sdk`) MCP servers,
which the CLI transport cannot host.
Expand All @@ -777,12 +778,14 @@ listed here is drift to remove rather than fork behavior to keep.
- **Terminals.** `terminal/Manager.ts` is upstream's. Coder deltas: it records each terminal's
attach events in a bounded replay window (`DEFAULT_ATTACH_REPLAY_*` in `Manager.ts`) and answers an
attach with `afterSequence` by replaying the missed events and a `resumed` event instead of a
snapshot when the window still covers the gap. Terminals poll the process table themselves
snapshot when the window still covers the gap. Each shell session restarts its sequences, so
it also has an `attachEpoch`, and a cursor whose `afterEpoch` names another session gets a
snapshot. Terminals poll the process table themselves
(no resource telemetry), register no ports for preview discovery, record no metrics, and get
no managed ACP install directories on `PATH`. In the browser, the terminal session state, output model,
drawer, and UI state store are upstream's; client-runtime adds `TerminalBufferCache`, a bounded
in-memory cache that seeds a reattach and supplies its `afterSequence`, and tracks the last
applied sequence. Each attach attempt reads `afterSequence` from that cache, beside upstream's
in-memory cache that seeds a reattach and supplies its `afterSequence` and `afterEpoch`, and
tracks the last applied sequence and session epoch. Each attach attempt reads `afterSequence` from that cache, beside upstream's
`resume` flag, and a resumed attach of a closed terminal gets upstream's `closed` event. `ThreadTerminalDrawer` never opens terminal links (there is no editor or
preview surface).
- **Command palette.** `CommandPalette.tsx` is upstream's, including its add-project browse,
Expand Down Expand Up @@ -927,7 +930,9 @@ listed here is drift to remove rather than fork behavior to keep.
[Network and transfer constraints](#network-and-transfer-constraints)), and a PDF keeps the
built-in viewer's frame where upstream uses `<embed>`, which the gateway's `object-src 'none'`
blocks; there is no
open-in-editor or reveal action, and the tree adds Copy path.
open-in-editor or reveal action, and the tree adds Copy path. Changed files in the Git actions
dialog open in the Files surface, with repository-relative status paths mapped to the thread's
folder; a file outside a subfolder project cannot be opened there.
`env.ts` reports `isElectron = false` for upstream's desktop branches.
- **Merge requests.** Upstream's page, panel, stack menu, and right-panel tabs, GitLab-only. Diffs
come over the `pullRequests.diff` RPC without upstream's cross-environment GitHub routing,
Expand Down
5 changes: 4 additions & 1 deletion packages/client-runtime/src/state/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,12 @@ export function createTerminalEnvironmentAtoms<R, E>(
// Coder: each attempt resumes after the last sequence the cache applied.
const current = terminalBufferCache.read(environmentId, input);
const afterSequence = current.version === 0 ? null : (current.sequence ?? null);
const afterEpoch = current.epoch ?? null;
return {
...(replied ? { ...input, resume: true } : input),
...(afterSequence === null ? {} : { afterSequence }),
...(afterSequence === null || afterEpoch === null
? {}
: { afterSequence, afterEpoch }),
};
}),
).pipe(
Expand Down
12 changes: 10 additions & 2 deletions packages/client-runtime/src/state/terminalSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export interface TerminalBufferState {
readonly lifecycleVersion: number;
/** Coder: the last applied attach-event sequence, sent as `afterSequence` on reattach. */
readonly sequence?: number | null;
/** Coder: the shell session that sequence belongs to, sent as `afterEpoch`. */
readonly epoch?: string | null;
}

export interface KnownTerminalSessionTarget {
Expand Down Expand Up @@ -152,9 +154,15 @@ export function applyTerminalAttachStreamEvent(
: "sequence" in event && typeof event.sequence === "number"
? event.sequence
: (current.sequence ?? null);
return next === current && sequence === (current.sequence ?? null)
const epoch =
event.type === "snapshot" || event.type === "restarted"
? (event.snapshot.attachEpoch ?? null)
: (current.epoch ?? null);
return next === current &&
sequence === (current.sequence ?? null) &&
epoch === (current.epoch ?? null)
? current
: { ...next, sequence };
: { ...next, sequence, epoch };
}

function applyUpstreamTerminalAttachStreamEvent(
Expand Down
6 changes: 5 additions & 1 deletion packages/contracts/src/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@ export const TerminalAttachInput = Schema.Struct({
env: Schema.optional(TerminalEnvSchema),
providerInstanceId: Schema.optional(ProviderInstanceId),
restartIfNotRunning: Schema.optional(Schema.Boolean),
// Coder: the last event sequence the client applied, for replay-window resume.
// Coder: the last event sequence the client applied, for replay-window resume, and the session
// epoch it was applied in; sequences restart with every new shell session.
afterSequence: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
afterEpoch: Schema.optional(Schema.String),
// Resubscriptions preserve stopped terminals; unknown IDs recover after a server restart.
resume: Schema.optional(Schema.Boolean),
});
Expand Down Expand Up @@ -113,6 +115,8 @@ export const TerminalSessionSnapshot = Schema.Struct({
label: Schema.String.check(Schema.isMaxLength(128)),
updatedAt: Schema.String,
sequence: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
// Coder: identifies the shell session whose sequences a replay-window resume refers to.
attachEpoch: Schema.optional(Schema.String),
});
export type TerminalSessionSnapshot = typeof TerminalSessionSnapshot.Type;

Expand Down