diff --git a/apps/coder-gateway/src/hub.ts b/apps/coder-gateway/src/hub.ts index 5600c44d646f9..a6385b4a78a04 100644 --- a/apps/coder-gateway/src/hub.ts +++ b/apps/coder-gateway/src/hub.ts @@ -162,7 +162,19 @@ export function makeCoderHub(options: CoderHubOptions): CoderHub { (entry) => entry.from === requester.workspaceId && entry.to === targetWorkspaceId, ); let outcome: CallOutcome | undefined; - if (targetWorkspaceId === undefined || rule === undefined) { + if ( + targetWorkspaceId === undefined && + (config.workspaceLinks ?? []).some( + (entry) => entry.from === requester.workspaceId && !environmentByWorkspace.has(entry.to), + ) + ) { + // A linked workspace that has not connected since the gateway started has no known + // environment id yet; it may be the target, so the caller retries instead of failing. + outcome = failed( + "orchestration_error", + "That workspace has not connected to T3 Coder yet. Retry once it is connected.", + ); + } else if (targetWorkspaceId === undefined || rule === undefined) { outcome = failed( "capability_denied", "This workspace is not linked to that one. Add a link in T3 Coder's workspace Links settings.", @@ -228,6 +240,17 @@ export function makeCoderHub(options: CoderHubOptions): CoderHub { try { request = decodeRequest(value); } catch { + // Answer what can be answered, so the caller fails now rather than at its timeout. + if (isRecord(value) && typeof value.id === "string") { + reply(entry, { + _tag: "Call", + id: value.id, + outcome: failed( + "orchestration_error", + "T3 Coder's gateway could not read this request. Reconnect the workspace so its helper matches the gateway.", + ), + }); + } continue; } if (request._tag === "ListLinks") { diff --git a/apps/server/src/coderHub.test.ts b/apps/server/src/coderHub.test.ts index ac6b19bb49c27..b6c1a942414d2 100644 --- a/apps/server/src/coderHub.test.ts +++ b/apps/server/src/coderHub.test.ts @@ -182,7 +182,7 @@ it.effect("refuses an invoked tool while Linked environments is off here", () => bound: true, }); assert.strictEqual(outcome._tag, "Failure"); - if (outcome._tag === "Failure") assert.strictEqual(outcome.failure.code, "capability_denied"); + if (outcome._tag === "Failure") assert.strictEqual(outcome.failure.code, "orchestration_error"); assert.strictEqual(scopes.length, 0); }), ); diff --git a/apps/server/src/coderHub.ts b/apps/server/src/coderHub.ts index 2c3ea467205d0..1f40d3b2ee047 100644 --- a/apps/server/src/coderHub.ts +++ b/apps/server/src/coderHub.ts @@ -232,10 +232,12 @@ export const makeHandlers = Effect.gen(function* () { if (!enabled) { return { _tag: "Failure", + // As upstream's PeerMcpClient: not final, so a task already running here resumes once + // the beta is on here again. failure: new OrchestratorMcpFailure({ - code: "capability_denied", + code: "orchestration_error", message: - "Linked environments is off in that workspace. Turn it on in its Settings → Labs.", + "The linked workspace has Linked environments turned off. Turn it on in its Settings → Labs.", }), } satisfies CoderHubCallOutcome; } diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index f03ec0aecaf13..587c434964da6 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -2076,6 +2076,7 @@ const make = Effect.gen(function* () { Effect.orElseSucceed(() => "timed_out" as const), ); yield* Effect.annotateCurrentSpan({ "secret_request.status": status }); + // Coder: no orchestration metrics (no secretRequestsTotal counter). if (status !== "saved") return { status }; if (input.delivery === "file") { const file = yield* secretRequests diff --git a/apps/web/src/components/ContextChip.tsx b/apps/web/src/components/ContextChip.tsx index b6abe578770c8..a49fcb59a9955 100644 --- a/apps/web/src/components/ContextChip.tsx +++ b/apps/web/src/components/ContextChip.tsx @@ -27,6 +27,7 @@ const contextChipVariants = cva( "pr-closed": "[--context-chip-accent:var(--context-chip-pr-closed)]", skill: "[--context-chip-accent:var(--context-chip-skill)]", thread: "[--context-chip-accent:var(--context-chip-thread)]", + // Coder: no machine (environment) chips; the @-machine picker is not carried. citation: "[--context-chip-accent:var(--context-chip-citation)]", "doc-pdf": "[--context-chip-accent:var(--context-chip-doc-pdf)]", "doc-word": "[--context-chip-accent:var(--context-chip-doc-word)]", diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7ef6f7fbf58c3..a5ffb85500fa9 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2660,6 +2660,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useThreadContextDrop(composerFormRef, environmentId, attachmentTargetKey, (refs, isActive) => { const records = refs.flatMap((ref) => { + // Coder: a thread from another environment is imported, as before #26201's linked machines. if (ref.environmentId !== environmentId) { void importDroppedThread( ref, diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 61c71c100ce83..120d297c63676 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -78,6 +78,7 @@ export type ComposerCommandItem = label: string; description: string; }; +// Coder: no machine (environment) chips; the @-machine picker is not carried. export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { listId: string; diff --git a/apps/web/src/components/chat/composerContextUndo.ts b/apps/web/src/components/chat/composerContextUndo.ts index 45a1410b1a5dc..4f1b481f727a9 100644 --- a/apps/web/src/components/chat/composerContextUndo.ts +++ b/apps/web/src/components/chat/composerContextUndo.ts @@ -14,6 +14,7 @@ export interface RetainedContextPayloads extends RetainedAttachmentContextPayloa terminals: Map; reviewComments: Map; threads: Map; + // Coder: no machine (environment) chips; the @-machine picker is not carried. } function createRetainedContextPayloads(): RetainedContextPayloads { diff --git a/apps/web/src/components/chat/useComposerContextImport.ts b/apps/web/src/components/chat/useComposerContextImport.ts index 082c6397fca32..42cd1e77e1427 100644 --- a/apps/web/src/components/chat/useComposerContextImport.ts +++ b/apps/web/src/components/chat/useComposerContextImport.ts @@ -312,6 +312,7 @@ export function useComposerContextImport(input: { } case "thread": { // The agent can only read threads on its own server; a pasted foreign one is dropped. + // Coder: no linked-machine exception (#26201 is not carried). if (record.environmentId !== environmentId) break; addComposerDraftThreadContexts(threadDraftTarget, [record], { appendReference: false, diff --git a/apps/web/src/components/composerContextPresentation.tsx b/apps/web/src/components/composerContextPresentation.tsx index 3c955e10a897a..b232f079062b3 100644 --- a/apps/web/src/components/composerContextPresentation.tsx +++ b/apps/web/src/components/composerContextPresentation.tsx @@ -276,6 +276,7 @@ function ComposerReviewCommentDetails({ comment }: { comment: ReviewCommentConte function ComposerThreadContextChip(props: { record: ThreadContextRecord }) { const { environmentId } = use(ComposerContextActionsContext); + // Coder: a thread on a linked machine still reads as unreadable (#26201 is not carried). return ( updateConfig((current) => { + // The id is the origin that work started over the link carries, which its fence + // compares; a rule pointed at other workspaces is a new link with a new id. + const sameEndpoints = link !== null && link.from === from && link.to === to; const next: CoderWorkspaceLinkProfile = { - id: link?.id ?? `workspace-link-${randomUUID()}`, + id: sameEndpoints ? link.id : `workspace-link-${randomUUID()}`, from, to, access, diff --git a/apps/web/src/lib/contextChipAccents.ts b/apps/web/src/lib/contextChipAccents.ts index 683da019443fe..0270fa0a61ad7 100644 --- a/apps/web/src/lib/contextChipAccents.ts +++ b/apps/web/src/lib/contextChipAccents.ts @@ -14,6 +14,7 @@ export const CONTEXT_CHIP_ACCENTS = { "pr-closed": "oklch(0.62 0.16 16)", skill: "oklch(0.62 0.16 322)", thread: "oklch(0.62 0.12 190)", + // Coder: no machine (environment) chips; the @-machine picker is not carried. citation: "oklch(0.62 0.16 259)", "doc-pdf": "oklch(0.62 0.16 24)", "doc-word": "oklch(0.62 0.136 248)", diff --git a/docs/internals/coder-only.md b/docs/internals/coder-only.md index fa66ed21f77e1..61a43ee71bb9d 100644 --- a/docs/internals/coder-only.md +++ b/docs/internals/coder-only.md @@ -526,13 +526,17 @@ listed here is drift to remove rather than fork behavior to keep. `WorkspaceLinkSettings.tsx`), runs the call on the target workspace's helper with `coder.hub.invoke` as that link's caller (`sessionId` `coder-link:`, the rule's access, the calling agent's modes as `narrowedTo`), and replies; the browser bridge refuses the hub - methods. A workspace that is not connected is unreachable, and upstream's follower retries. + methods. A workspace that is not connected is unreachable (`orchestration_error`, including a + linked workspace that has not connected since the gateway started), and upstream's follower + retries; a call with no matching rule is refused for good (`capability_denied`). Editing a + rule's endpoints mints a new rule id, since the id is the origin the fence compares. Differences, each marked `Coder:`: no `T3-Mode-Limit` header (the hub carries the limits); `PeerLinks` has no pairing, storage, or expiry (`expiresAt` is a fixed far-future value); `t3_environment_link`, `t3_environment_unlink`, `PeerLinkRequests`, the link-request card, and the `WsPeerLinks*` handlers are not carried, so `t3_environment_links`' `notListed` and the orchestration instructions point to the Links settings; `coder.hub.invoke` refuses calls while - Labs' switch is off, as upstream's `/mcp` does; `T3ToolBridge`'s binding gains `invoke` for a + Labs' switch is off, as upstream's `/mcp` does, with upstream `PeerMcpClient`'s non-final + `orchestration_error` so a running task resumes once the switch is on again; `T3ToolBridge`'s binding gains `invoke` for a linked caller; and upstream's peer tests (`PeerForwarding.test.ts`, `RemoteDelegation.test.ts`, `linkOrigin.test.ts`, `PeerLinks.testkit.ts`) are not carried because they pair two HTTP servers; `coderHub.test.ts` and the gateway's `hub.test.ts` test the transport. Migrations 070 @@ -547,7 +551,8 @@ listed here is drift to remove rather than fork behavior to keep. exports; the `@` machine menu items and machine chips in the composer and drafts (`useComposerMenuItems.ts`, `ComposerCommandMenu.tsx`, `composerThreadItems.ts`, `composerDraftStore.ts`, `composerContextRecords.ts`, `composerContextPresentation.tsx`, - `useComposerSend.ts`, `chatViewSend.ts`, `useQueuedRunEdit.ts`); the link-request timeline + `composerContextUndo.ts`, `contextChipAccents.ts`, `ContextChip.tsx`, `useComposerSend.ts`, + `chatViewSend.ts`, `useQueuedRunEdit.ts`); the link-request timeline card (`TimelineV2EventRow.tsx`, `session-logic.ts`); and the `t3_environment_link`/`unlink` tool presentations (`t3McpToolPresentation.ts`, `t3ToolSummary.ts`). The contract's `environment` record kind keeps upstream's definition in `contextPresentationRegistry.ts` and @@ -571,7 +576,7 @@ listed here is drift to remove rather than fork behavior to keep. `CODER_OMITTED_CAPABILITIES`. Web `chat/ThreadServersPanel.tsx`, its `ThreadDetailsPanel.tsx` section, and its `thread-sidebar.md` paragraph are cut. - The `@` picker lists no threads on linked machines (#26201): `composerThreadItems.ts`, shared - `composerContextReferences.ts` (`strandedThreadContexts`), and the composer hooks keep the + `composerContextReferences.ts` (`threadContextsOutsideEnvironment`), and the composer hooks keep the same-server rules from before #26201. Copy thread (#26202) is upstream's. - F6 workspace navigation (#25877) is upstream's without the desktop menu-action forwarding in `AppSidebarLayout.tsx`; the Merge requests page's area is labelled "Merge requests" @@ -1135,7 +1140,7 @@ listed here is drift to remove rather than fork behavior to keep. `canonicalizeGitCafePullRequestUrl` is an identity function, since GitCafe is not carried. Diff output is capped (`DIFF_MAX_OUTPUT_BYTES`) to fit the gateway's `MAX_RPC_MESSAGE_BYTES` ceiling. - GitLab fixes for bugs still in upstream's `GitLabCli` (checked against upstream `main` - e9236274a3): + 67661cfbb5): merge requests between projects post to the source project with a numeric `target_project_id` instead of sending the repository text as `source_project_id`; merge request URLs reach `glab` as `--repo ... -- ` because older versions read URLs as diff --git a/docs/internals/sync.md b/docs/internals/sync.md index 184fa419598bc..abd3d95f35ce6 100644 --- a/docs/internals/sync.md +++ b/docs/internals/sync.md @@ -171,6 +171,9 @@ mobile's `microphonePriority.ts` into `packages/shared`) arrives as an added fil `nativeLogging.ts`). - Desktop (`apps/desktop`, `native`, `packaging`, `assets`); mobile (`apps/mobile`); hosted web (`apps/marketing`, `apps/server/src/cloud`, web `cloud`, `components/clerk`). +- Triage, dev servers, and dev replay (`apps/server/src/triage`, `apps/server/scripts/dev-replay*`, + web `DevReplayHost.tsx`, `chat/ThreadServersPanel.tsx`, `chat/TriageDraftHero.tsx`, + `hooks/useStartTriage.ts`, `lib/triageTarget.ts`, `docs/user/triage.md`). - Relay (`apps/server/src/relay`, `infra`, `packages/ssh`, `packages/tailscale`); telemetry (`apps/server/src/{telemetry,resourceTelemetry,usage,diagnostics}`, web `components/usage`). - Upstream repository tooling (`.github`, `.repos`, `oxlint-plugin-t3code`, `docs/operations`, diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index db92259b6031b..38b1b3a04f6b1 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -132,3 +132,6 @@ an available shortcut such as `alt+w`. Many defaults include `!terminalFocus` so they do not intercept terminal input. Keep that condition when remapping them if you want the same behavior. + +See [Keyboard focus](./keyboard-focus.md) for how focus moves between workspace areas (`F6`) +and through the sidebar. diff --git a/docs/user/keyboard-focus.md b/docs/user/keyboard-focus.md new file mode 100644 index 0000000000000..100d32ba46cb4 --- /dev/null +++ b/docs/user/keyboard-focus.md @@ -0,0 +1,37 @@ +# Keyboard focus + +The command palette keeps focus while open. Closing it returns focus to the composer in chat, +or to the previous control on other pages. Choosing a workspace focus command moves to that +area instead. Opening Merge requests moves focus into that page. +While the palette or model picker is open, number shortcuts select its entries instead of +switching threads. Model shortcuts work in Settings as well as the composer. +See [Keybindings](./keybindings.md) to customize these shortcuts. + +If you return to typing while a terminal is starting, the composer keeps focus when the terminal +becomes ready. Opening or switching to a terminal explicitly still focuses it. + +## Move between workspace areas + +Press `F6` or `Shift+F6` to move between the Threads sidebar, +conversation, message input, and visible panels. Navigation wraps around and restores +the last focused control in each area. Merge requests has a page content area. In Settings, +`F6` moves between the section list and the open section. Hidden areas are skipped. Other +dialogs and form popups retain focus; a thread context menu closes when an area command +moves focus away. + +In a browser, plain `F6` may move to the browser's own toolbar. Use `mod+F6` and +`mod+Shift+F6` instead (`mod` is Command on macOS and Ctrl elsewhere). **Focus Next +Workspace Area** and **Focus Previous Workspace Area** are also in the command palette and +can be remapped in Settings > Keybindings. + +## Browse threads + +Tab enters the sidebar once. Up and Down move between threads without opening them; Enter +opens the focused thread or toggles a group. Right and Left expand and collapse groups. +Tab moves from the focused thread to its buttons. `F2` renames the focused thread. + +Press `Shift+F10` or the Context Menu key to open the focused thread's menu. Use the arrow +keys to move through it, Enter to choose, and Escape to close it. + +The Settings section list works the same way: Up and Down move between sections, and Enter +opens one. diff --git a/scripts/sync-resolve-deleted.mjs b/scripts/sync-resolve-deleted.mjs index 9b94973084e49..21f0dd326cd1b 100644 --- a/scripts/sync-resolve-deleted.mjs +++ b/scripts/sync-resolve-deleted.mjs @@ -156,6 +156,21 @@ const DELETED_PATHS = { "packages/contracts/src/resourceTelemetry.ts", "packages/contracts/src/usage.ts", ], + // Triage, dev servers, and dev replay: no in-app triage of upstream issues, no dev-server panel + // (it needs the preview port scanner), and no dev-only replay provider. + "triage, dev servers, and dev replay": [ + "apps/server/src/triage/", + "apps/server/scripts/dev-replay/", + "apps/server/scripts/dev-replay-agent.ts", + "apps/server/scripts/dev-replay-agent.test.ts", + "apps/web/src/components/DevReplayHost.tsx", + "apps/web/src/components/chat/ThreadServersPanel.tsx", + "apps/web/src/components/chat/TriageDraftHero.tsx", + "apps/web/src/hooks/useStartTriage.ts", + "apps/web/src/lib/triageTarget.ts", + "apps/web/src/lib/triageTarget.test.ts", + "docs/user/triage.md", + ], // Upstream repository tooling: vendored sources, CI, hooks, editor and agent configuration, // and operations docs. The fork's .github/ keeps only SECURITY.md. "upstream repository tooling": [