mirror of
https://github.com/coder/coder.git
synced 2026-10-16 06:37:58 +08:00
feat: correlate web terminal sessions by client_session_id (#27677)
## What Implements the web terminal client half of the [Connection log collection and correlation RFC](https://www.notion.so/coderhq/Connection-log-collection-and-correlation-36ed579be5928025a56cd11fe58661fb) (`DEVEX-663`). Generates a per-session correlation ID and attaches it to the web terminal's requests and client logs so a single session can be traced end to end. > Stacked on #27671 (`DEVEX-659`, the coderd tracing middleware that reads the > `client_session_id` baggage). Review/merge that first. ## Changes **Session ID** - 16-byte value encoded as a 32-character hex string, per RFC requirement 1. Generated with the `generateConnectionSessionId` function added in #27935 - Minted once per web terminal session: on `TerminalPage` load and on `AgentsPage` terminal panel mount. Unlike the reconnection token, it is **not** persisted in the URL, so a reload (or a new tab) is a new session, matching the RFC's session definition. **Propagation** - **HTTP API request:** the reconnecting-pty signed-token request carries the ID via W3C baggage (`baggage: client_session_id=<hex>`), which the DEVEX-659 middleware reads. - **PTY WebSocket:** browsers cannot set the `baggage` header on a WebSocket handshake (the codebase already works around this for the session token), so the ID is sent as a `client_session_id` query parameter instead. The reconnecting-pty WebSocket handler reads and validates it and attaches it to the request and PTY logs. **Client logs** - The terminal's connection-error `console.error` logs now include `client_session_id`. **Telemetry:** the web terminal emits none today, so there is nothing to tag (confirmed with the issue reporter). ## Testing - `site`: unit tests for `generateSessionId` (format + uniqueness) and `terminalWebsocketUrl` (query param). Updated `TerminalPage.test.tsx` (mocks the generator to a fixed ID and asserts the WebSocket URL includes `client_session_id`). `tsc`, Biome, and the React Compiler check pass. - `coderd`: `go test ./coderd/tracing/...` and `go vet ./coderd/workspaceapps/...` pass; new `ValidSessionID` export reused by the PTY handler. <details> <summary>Design notes / decision log</summary> - **Session vs reconnection token.** The existing `reconnect` token is deliberately persisted in the URL to survive reloads. `client_session_id` is the opposite: a fresh value per page load, matching the RFC (a reload is a new session). They are separate identifiers. - **Per-request baggage, not a global axios default.** The frontend axios instance is a singleton shared by the whole app; a global `baggage` default would tag unrelated requests. The header is attached only to the terminal's signed-token request. - **WebSocket uses a query param.** Browser `WebSocket` cannot send custom headers, so baggage is impossible on the PTY handshake. The `client_session_id` query parameter is the counterpart, read server-side in `workspaceAgentPTY`. - **Server-side scope.** Reading the query param is localized to the web terminal's own WebSocket handler rather than broadening the shared tracing middleware to trust query params on every route. - **Validation.** Both the baggage and query-param paths validate the value as a 32-char hex string (`tracing.ValidSessionID`) before logging, to avoid logging arbitrary client-controlled input. - **AgentsPage compiler constraint.** `AgentsPage` is React Compiler optimized (no `useMemo`/`useCallback`), so the panel mints its ID with `useState` lazy init instead. - **Out of scope (other RFC tickets):** agent-side middleware, `connection_logs` columns, Tailnet state-change logging, and the CLI `CODER_TRACE_SESSION_ID` env var. </details> --- _Opened by Coder Agents on behalf of @aqandrew._ --------- Co-authored-by: Danielle Maywood <danielle@themaywoods.com>
This commit is contained in:
co-authored by
Danielle Maywood
parent
e5fa18b58e
commit
197c814070
@@ -2753,10 +2753,12 @@ class ApiMethods {
|
||||
|
||||
issueReconnectingPTYSignedToken = async (
|
||||
params: TypesGen.IssueReconnectingPTYSignedTokenRequest,
|
||||
sessionId: string,
|
||||
): Promise<TypesGen.IssueReconnectingPTYSignedTokenResponse> => {
|
||||
const response = await this.axios.post(
|
||||
"/api/v2/applications/reconnecting-pty-signed-token",
|
||||
params,
|
||||
{ headers: { baggage: `client_session_id=${sessionId}` } },
|
||||
);
|
||||
|
||||
return response.data;
|
||||
|
||||
@@ -52,6 +52,12 @@ type WorkspaceTerminalProps = {
|
||||
onError?: (error: Error) => void;
|
||||
onContentReady?: () => void;
|
||||
reconnectionToken: string;
|
||||
/**
|
||||
* The session ID correlates all logs, requests, and telemetry for this
|
||||
* terminal session. Unlike the reconnection token, it is not persisted in
|
||||
* the URL: a new page load (including a reload) is a new session.
|
||||
*/
|
||||
sessionId: string;
|
||||
baseUrl?: string;
|
||||
terminalFontFamily?: string;
|
||||
renderer?: string;
|
||||
@@ -83,6 +89,7 @@ export const WorkspaceTerminal = ({
|
||||
onError,
|
||||
onContentReady,
|
||||
reconnectionToken,
|
||||
sessionId,
|
||||
baseUrl,
|
||||
terminalFontFamily = DEFAULT_TERMINAL_FONT_FAMILY,
|
||||
renderer,
|
||||
@@ -458,6 +465,7 @@ export const WorkspaceTerminal = ({
|
||||
initialDimensions.width,
|
||||
containerName,
|
||||
containerUser,
|
||||
sessionId,
|
||||
)
|
||||
.then((url) => {
|
||||
if (disposed) {
|
||||
@@ -504,7 +512,10 @@ export const WorkspaceTerminal = ({
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
console.error("WebSocket error:", event);
|
||||
console.error("WebSocket error:", {
|
||||
client_session_id: sessionId,
|
||||
event,
|
||||
});
|
||||
terminal.options.disableStdin = true;
|
||||
handleStatusChange("disconnected");
|
||||
});
|
||||
@@ -550,7 +561,10 @@ export const WorkspaceTerminal = ({
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
console.error("WebSocket connection failed:", error);
|
||||
console.error("WebSocket connection failed:", {
|
||||
client_session_id: sessionId,
|
||||
error,
|
||||
});
|
||||
reportTerminalError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
@@ -577,6 +591,7 @@ export const WorkspaceTerminal = ({
|
||||
loading,
|
||||
operatingSystem,
|
||||
reconnectionToken,
|
||||
sessionId,
|
||||
refit,
|
||||
terminal,
|
||||
]);
|
||||
|
||||
@@ -28,7 +28,7 @@ import { findWorkspaceAppWithAgent } from "#/modules/apps/workspaceApps";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import { generateUUID } from "#/utils/random";
|
||||
import { generateConnectionSessionId, generateUUID } from "#/utils/random";
|
||||
import { findWorkspaceAgent } from "#/utils/workspace";
|
||||
import {
|
||||
AgentChatInput,
|
||||
@@ -60,6 +60,7 @@ import { RightPanelAddTabControl } from "./components/RightPanel/RightPanelAddTa
|
||||
import { getWorkspaceStatus, StatusIcon } from "./components/StatusIcon";
|
||||
import { TerminalPanel } from "./components/TerminalPanel";
|
||||
import { ChatWorkspaceContext } from "./context/ChatWorkspaceContext";
|
||||
import { TerminalClientSessionContext } from "./context/TerminalClientSessionContext";
|
||||
import { chatWidthClass, useChatFullWidth } from "./hooks/useChatFullWidth";
|
||||
import {
|
||||
getPersistedDefaultTerminalHidden,
|
||||
@@ -451,6 +452,10 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
const [defaultTerminalHidden, setDefaultTerminalHiddenState] =
|
||||
useState<boolean>(() => getPersistedDefaultTerminalHidden(agentId));
|
||||
const [pendingTabId, setPendingTabId] = useState<string | null>(null);
|
||||
// One client session ID per page visit, shared by every terminal in this
|
||||
// chat. It regenerates when this view remounts (switching chats or
|
||||
// reloading), independent of any terminal's reconnection token.
|
||||
const [clientSessionId] = useState(generateConnectionSessionId);
|
||||
|
||||
const setSidebarTabId = (tabId: string) => {
|
||||
setSidebarTabIdState(tabId);
|
||||
@@ -857,232 +862,240 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatWorkspaceContext
|
||||
value={{ workspaceId: workspace?.id, buildId: chatBuildId }}
|
||||
>
|
||||
<DesktopPanelContext value={desktopPanelCtx}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-0 min-w-0 flex-1 sm:[--agents-chat-panel-min-width:360px]",
|
||||
shouldShowSidebar && !visualExpanded && "flex-row",
|
||||
)}
|
||||
>
|
||||
{titleElement}
|
||||
<TerminalClientSessionContext value={clientSessionId}>
|
||||
<ChatWorkspaceContext
|
||||
value={{ workspaceId: workspace?.id, buildId: chatBuildId }}
|
||||
>
|
||||
<DesktopPanelContext value={desktopPanelCtx}>
|
||||
<div
|
||||
data-testid="agents-chat-panel"
|
||||
className={cn(
|
||||
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden sm:min-w-[var(--agents-chat-panel-min-width,0px)]",
|
||||
visualExpanded && "hidden",
|
||||
shouldShowSidebar && "max-lg:hidden",
|
||||
"relative flex min-h-0 min-w-0 flex-1 sm:[--agents-chat-panel-min-width:360px]",
|
||||
shouldShowSidebar && !visualExpanded && "flex-row",
|
||||
)}
|
||||
>
|
||||
<div className="relative z-10 shrink-0 overflow-visible">
|
||||
{" "}
|
||||
<ChatTopBar
|
||||
chatTitle={chatTitle}
|
||||
parentChat={parentChat}
|
||||
panel={{
|
||||
showSidebarPanel,
|
||||
onToggleSidebar: () =>
|
||||
onSetShowSidebarPanel(!showSidebarPanel),
|
||||
}}
|
||||
onArchiveAgent={handleArchiveAgentAction}
|
||||
onUnarchiveAgent={handleUnarchiveAgentAction}
|
||||
onArchiveAndDeleteWorkspace={
|
||||
handleArchiveAndDeleteWorkspaceAction
|
||||
}
|
||||
onPinAgent={handlePinAgentAction}
|
||||
onUnpinAgent={handleUnpinAgentAction}
|
||||
onOpenRenameDialog={handleOpenRenameDialogAction}
|
||||
isPinned={isPinned}
|
||||
isChildChat={isChildChat}
|
||||
isArchiving={isArchivingThisChat}
|
||||
isArchiveBlocked={isArchiveBlocked}
|
||||
hasWorkspace={Boolean(workspace)}
|
||||
isArchived={isArchived}
|
||||
diffStatusData={diffStatusData}
|
||||
isSharedChat={isSharedChat}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
|
||||
renderChatSharingContent={
|
||||
canOpenChatSharing
|
||||
? (open) => (
|
||||
<ChatSharingPopoverContent
|
||||
chatId={agentId}
|
||||
organizationId={organizationId}
|
||||
open={open}
|
||||
/>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{modelCatalogError != null && (
|
||||
<ErrorAlert error={modelCatalogError} />
|
||||
{titleElement}
|
||||
<div
|
||||
data-testid="agents-chat-panel"
|
||||
className={cn(
|
||||
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden sm:min-w-[var(--agents-chat-panel-min-width,0px)]",
|
||||
visualExpanded && "hidden",
|
||||
shouldShowSidebar && "max-lg:hidden",
|
||||
)}
|
||||
{unavailableModelNotice && (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={unavailableModelNotice}
|
||||
aria-live="polite"
|
||||
className="flex shrink-0 items-center gap-2 border-b border-border-warning bg-surface-orange px-4 py-2 text-xs text-content-primary"
|
||||
>
|
||||
<TriangleAlertIcon className="size-4 shrink-0 text-content-warning" />
|
||||
{unavailableModelNotice}
|
||||
</div>
|
||||
)}
|
||||
{chatOwnerWarning && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex shrink-0 items-center gap-2 border-b border-border-warning bg-surface-orange px-4 py-2 text-xs text-content-primary"
|
||||
>
|
||||
<TriangleAlertIcon className="size-4 shrink-0 text-content-warning" />
|
||||
{chatOwnerWarning}
|
||||
</div>
|
||||
)}
|
||||
{isArchived && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border-default bg-surface-secondary px-4 py-2 text-xs text-content-secondary">
|
||||
<ArchiveIcon className="size-4 shrink-0" />
|
||||
This agent has been archived and is read-only.
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-full z-10 h-3 sm:h-6 bg-surface-primary"
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ChatPageTimeline
|
||||
key={agentId}
|
||||
organizationId={organizationId}
|
||||
store={store}
|
||||
initialActiveTurnMaxMessageId={initialActiveTurnMaxMessageId}
|
||||
persistedError={persistedError}
|
||||
hasMoreMessages={hasMoreMessages}
|
||||
isFetchingMoreMessages={isFetchingMoreMessages}
|
||||
isHydratingMessages={isHydratingMessages}
|
||||
hasFetchMoreError={hasFetchMoreError}
|
||||
onFetchMoreMessages={onFetchMoreMessages}
|
||||
onEditUserMessage={
|
||||
isOtherUserReadOnly ? undefined : editing.handleEditUserMessage
|
||||
}
|
||||
editingMessageId={editing.editingMessageId}
|
||||
urlTransform={urlTransform}
|
||||
mcpServers={mcpServers}
|
||||
onImplementPlan={
|
||||
isOtherUserReadOnly ? undefined : onImplementPlan
|
||||
}
|
||||
onSendAskUserQuestionResponse={
|
||||
isOtherUserReadOnly ? undefined : canSendAskUserQuestionResponse
|
||||
}
|
||||
footer={
|
||||
queuedForCapacity ? (
|
||||
<QueuedForCapacityCallout
|
||||
hasLicense={hasLicense}
|
||||
canManageLicenses={canManageLicenses}
|
||||
agentHoursHardLimit={agentHoursHardLimit}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="shrink-0 overflow-y-auto px-4 pb-3 md:pb-0 [scrollbar-gutter:stable] [scrollbar-width:thin]">
|
||||
<ChatPageInput
|
||||
organizationId={organizationId}
|
||||
sendShortcut={sendShortcut}
|
||||
store={store}
|
||||
compressionThreshold={compressionThreshold}
|
||||
onSend={editing.handleSendFromInput}
|
||||
onDeleteQueuedMessage={handleDeleteQueuedMessage}
|
||||
onPromoteQueuedMessage={handlePromoteQueuedMessage}
|
||||
onInterrupt={handleInterrupt}
|
||||
isInputDisabled={isInputDisabled}
|
||||
isSendPending={isSubmissionPending}
|
||||
isInterruptPending={isInterruptPending}
|
||||
hasModelOptions={hasModelOptions}
|
||||
canConfigureAgentSetup={canConfigureAgentSetup}
|
||||
providerCount={providerCount}
|
||||
modelCount={modelCount}
|
||||
unsupportedProviderNames={unsupportedProviderNames}
|
||||
aiGatewayDisabled={aiGatewayDisabled}
|
||||
selectedModel={effectiveSelectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
modelOptions={modelOptions}
|
||||
modelSelectorPlaceholder={modelSelectorPlaceholder}
|
||||
modelSelectorHelp={modelSelectorHelp}
|
||||
reasoningEffort={reasoningEffort}
|
||||
onReasoningEffortChange={onReasoningEffortChange}
|
||||
planModeEnabled={planModeEnabled}
|
||||
onPlanModeToggle={onPlanModeToggle}
|
||||
isModelCatalogLoading={isModelCatalogLoading}
|
||||
workspaceOptions={workspaceOptions}
|
||||
chatOrganizationId={organizationId}
|
||||
selectedWorkspaceId={selectedWorkspaceId}
|
||||
onWorkspaceChange={onWorkspaceChange}
|
||||
isWorkspaceLoading={isWorkspaceLoading}
|
||||
inputRef={editing.chatInputRef}
|
||||
initialValue={editing.editorInitialValue}
|
||||
initialEditorState={editing.initialEditorState}
|
||||
remountKey={editing.remountKey}
|
||||
onContentChange={editing.handleContentChange}
|
||||
isEditing={isEditing}
|
||||
onCancelHistoryEdit={editing.handleCancelHistoryEdit}
|
||||
editingFileBlocks={editing.editingFileBlocks}
|
||||
mcpServers={mcpServers}
|
||||
selectedMCPServerIds={selectedMCPServerIds}
|
||||
onMCPSelectionChange={onMCPSelectionChange}
|
||||
onMCPAuthComplete={onMCPAuthComplete}
|
||||
chatContext={chatContext}
|
||||
workspaceSkills={workspaceSkills}
|
||||
workspace={workspace}
|
||||
workspaceAgent={workspaceAgent}
|
||||
chatId={agentId}
|
||||
sshCommand={sshCommand}
|
||||
attachedWorkspace={attachedWorkspace}
|
||||
folder={preferredFolder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<RightPanel
|
||||
isOpen={shouldShowSidebar}
|
||||
isExpanded={showSidebarPanel && isRightPanelExpanded}
|
||||
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
|
||||
onClose={() => onSetShowSidebarPanel(false)}
|
||||
onVisualExpandedChange={setDragVisualExpanded}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
|
||||
>
|
||||
<SidebarTabView
|
||||
effectiveTabId={effectiveSidebarTabId}
|
||||
onActiveTabChange={handleActiveTabChange}
|
||||
tabs={sidebarTabs}
|
||||
addTabControl={
|
||||
<RightPanelAddTabControl
|
||||
workspace={workspace}
|
||||
agent={workspaceAgent}
|
||||
host={wildcardHostname}
|
||||
isRunning={workspace?.latest_build.status === "running"}
|
||||
onNewTerminal={handleAddTerminalTab}
|
||||
onOpenWorkspaceApp={handleOpenWorkspaceAppTab}
|
||||
onOpenCommandApp={handleOpenCommandAppTab}
|
||||
onOpenPort={handleOpenPortTab}
|
||||
>
|
||||
<div className="relative z-10 shrink-0 overflow-visible">
|
||||
{" "}
|
||||
<ChatTopBar
|
||||
chatTitle={chatTitle}
|
||||
parentChat={parentChat}
|
||||
panel={{
|
||||
showSidebarPanel,
|
||||
onToggleSidebar: () =>
|
||||
onSetShowSidebarPanel(!showSidebarPanel),
|
||||
}}
|
||||
onArchiveAgent={handleArchiveAgentAction}
|
||||
onUnarchiveAgent={handleUnarchiveAgentAction}
|
||||
onArchiveAndDeleteWorkspace={
|
||||
handleArchiveAndDeleteWorkspaceAction
|
||||
}
|
||||
onPinAgent={handlePinAgentAction}
|
||||
onUnpinAgent={handleUnpinAgentAction}
|
||||
onOpenRenameDialog={handleOpenRenameDialogAction}
|
||||
isPinned={isPinned}
|
||||
isChildChat={isChildChat}
|
||||
isArchiving={isArchivingThisChat}
|
||||
isArchiveBlocked={isArchiveBlocked}
|
||||
hasWorkspace={Boolean(workspace)}
|
||||
isArchived={isArchived}
|
||||
diffStatusData={diffStatusData}
|
||||
isSharedChat={isSharedChat}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
|
||||
renderChatSharingContent={
|
||||
canOpenChatSharing
|
||||
? (open) => (
|
||||
<ChatSharingPopoverContent
|
||||
chatId={agentId}
|
||||
organizationId={organizationId}
|
||||
open={open}
|
||||
/>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
onClose={() => onSetShowSidebarPanel(false)}
|
||||
isExpanded={visualExpanded}
|
||||
{modelCatalogError != null && (
|
||||
<ErrorAlert error={modelCatalogError} />
|
||||
)}
|
||||
{unavailableModelNotice && (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={unavailableModelNotice}
|
||||
aria-live="polite"
|
||||
className="flex shrink-0 items-center gap-2 border-b border-border-warning bg-surface-orange px-4 py-2 text-xs text-content-primary"
|
||||
>
|
||||
<TriangleAlertIcon className="size-4 shrink-0 text-content-warning" />
|
||||
{unavailableModelNotice}
|
||||
</div>
|
||||
)}
|
||||
{chatOwnerWarning && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex shrink-0 items-center gap-2 border-b border-border-warning bg-surface-orange px-4 py-2 text-xs text-content-primary"
|
||||
>
|
||||
<TriangleAlertIcon className="size-4 shrink-0 text-content-warning" />
|
||||
{chatOwnerWarning}
|
||||
</div>
|
||||
)}
|
||||
{isArchived && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border-default bg-surface-secondary px-4 py-2 text-xs text-content-secondary">
|
||||
<ArchiveIcon className="size-4 shrink-0" />
|
||||
This agent has been archived and is read-only.
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-full z-10 h-3 sm:h-6 bg-surface-primary"
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ChatPageTimeline
|
||||
key={agentId}
|
||||
organizationId={organizationId}
|
||||
store={store}
|
||||
initialActiveTurnMaxMessageId={initialActiveTurnMaxMessageId}
|
||||
persistedError={persistedError}
|
||||
hasMoreMessages={hasMoreMessages}
|
||||
isFetchingMoreMessages={isFetchingMoreMessages}
|
||||
isHydratingMessages={isHydratingMessages}
|
||||
hasFetchMoreError={hasFetchMoreError}
|
||||
onFetchMoreMessages={onFetchMoreMessages}
|
||||
onEditUserMessage={
|
||||
isOtherUserReadOnly
|
||||
? undefined
|
||||
: editing.handleEditUserMessage
|
||||
}
|
||||
editingMessageId={editing.editingMessageId}
|
||||
urlTransform={urlTransform}
|
||||
mcpServers={mcpServers}
|
||||
onImplementPlan={
|
||||
isOtherUserReadOnly ? undefined : onImplementPlan
|
||||
}
|
||||
onSendAskUserQuestionResponse={
|
||||
isOtherUserReadOnly
|
||||
? undefined
|
||||
: canSendAskUserQuestionResponse
|
||||
}
|
||||
footer={
|
||||
queuedForCapacity ? (
|
||||
<QueuedForCapacityCallout
|
||||
hasLicense={hasLicense}
|
||||
canManageLicenses={canManageLicenses}
|
||||
agentHoursHardLimit={agentHoursHardLimit}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="shrink-0 overflow-y-auto px-4 pb-3 md:pb-0 [scrollbar-gutter:stable] [scrollbar-width:thin]">
|
||||
<ChatPageInput
|
||||
organizationId={organizationId}
|
||||
sendShortcut={sendShortcut}
|
||||
store={store}
|
||||
compressionThreshold={compressionThreshold}
|
||||
onSend={editing.handleSendFromInput}
|
||||
onDeleteQueuedMessage={handleDeleteQueuedMessage}
|
||||
onPromoteQueuedMessage={handlePromoteQueuedMessage}
|
||||
onInterrupt={handleInterrupt}
|
||||
isInputDisabled={isInputDisabled}
|
||||
isSendPending={isSubmissionPending}
|
||||
isInterruptPending={isInterruptPending}
|
||||
hasModelOptions={hasModelOptions}
|
||||
canConfigureAgentSetup={canConfigureAgentSetup}
|
||||
providerCount={providerCount}
|
||||
modelCount={modelCount}
|
||||
unsupportedProviderNames={unsupportedProviderNames}
|
||||
aiGatewayDisabled={aiGatewayDisabled}
|
||||
selectedModel={effectiveSelectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
modelOptions={modelOptions}
|
||||
modelSelectorPlaceholder={modelSelectorPlaceholder}
|
||||
modelSelectorHelp={modelSelectorHelp}
|
||||
reasoningEffort={reasoningEffort}
|
||||
onReasoningEffortChange={onReasoningEffortChange}
|
||||
planModeEnabled={planModeEnabled}
|
||||
onPlanModeToggle={onPlanModeToggle}
|
||||
isModelCatalogLoading={isModelCatalogLoading}
|
||||
workspaceOptions={workspaceOptions}
|
||||
chatOrganizationId={organizationId}
|
||||
selectedWorkspaceId={selectedWorkspaceId}
|
||||
onWorkspaceChange={onWorkspaceChange}
|
||||
isWorkspaceLoading={isWorkspaceLoading}
|
||||
inputRef={editing.chatInputRef}
|
||||
initialValue={editing.editorInitialValue}
|
||||
initialEditorState={editing.initialEditorState}
|
||||
remountKey={editing.remountKey}
|
||||
onContentChange={editing.handleContentChange}
|
||||
isEditing={isEditing}
|
||||
onCancelHistoryEdit={editing.handleCancelHistoryEdit}
|
||||
editingFileBlocks={editing.editingFileBlocks}
|
||||
mcpServers={mcpServers}
|
||||
selectedMCPServerIds={selectedMCPServerIds}
|
||||
onMCPSelectionChange={onMCPSelectionChange}
|
||||
onMCPAuthComplete={onMCPAuthComplete}
|
||||
chatContext={chatContext}
|
||||
workspaceSkills={workspaceSkills}
|
||||
workspace={workspace}
|
||||
workspaceAgent={workspaceAgent}
|
||||
chatId={agentId}
|
||||
sshCommand={sshCommand}
|
||||
attachedWorkspace={attachedWorkspace}
|
||||
folder={preferredFolder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<RightPanel
|
||||
isOpen={shouldShowSidebar}
|
||||
isExpanded={showSidebarPanel && isRightPanelExpanded}
|
||||
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
|
||||
onClose={() => onSetShowSidebarPanel(false)}
|
||||
onVisualExpandedChange={setDragVisualExpanded}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
|
||||
chatTitle={chatTitle}
|
||||
/>
|
||||
</RightPanel>
|
||||
</div>
|
||||
</DesktopPanelContext>
|
||||
</ChatWorkspaceContext>
|
||||
>
|
||||
<SidebarTabView
|
||||
effectiveTabId={effectiveSidebarTabId}
|
||||
onActiveTabChange={handleActiveTabChange}
|
||||
tabs={sidebarTabs}
|
||||
addTabControl={
|
||||
<RightPanelAddTabControl
|
||||
workspace={workspace}
|
||||
agent={workspaceAgent}
|
||||
host={wildcardHostname}
|
||||
isRunning={workspace?.latest_build.status === "running"}
|
||||
onNewTerminal={handleAddTerminalTab}
|
||||
onOpenWorkspaceApp={handleOpenWorkspaceAppTab}
|
||||
onOpenCommandApp={handleOpenCommandAppTab}
|
||||
onOpenPort={handleOpenPortTab}
|
||||
/>
|
||||
}
|
||||
onClose={() => onSetShowSidebarPanel(false)}
|
||||
isExpanded={visualExpanded}
|
||||
onToggleExpanded={() =>
|
||||
setIsRightPanelExpanded((prev) => !prev)
|
||||
}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
|
||||
chatTitle={chatTitle}
|
||||
/>
|
||||
</RightPanel>
|
||||
</div>
|
||||
</DesktopPanelContext>
|
||||
</ChatWorkspaceContext>
|
||||
</TerminalClientSessionContext>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
MockWorkspaceAgent,
|
||||
} from "#/testHelpers/entities";
|
||||
import { withProxyProvider, withWebSocket } from "#/testHelpers/storybook";
|
||||
import { TerminalClientSessionContext } from "../context/TerminalClientSessionContext";
|
||||
import { TerminalPanel } from "./TerminalPanel";
|
||||
|
||||
const terminalQueries = [
|
||||
@@ -43,9 +44,11 @@ const meta = {
|
||||
decorators: [
|
||||
withProxyProvider(),
|
||||
(Story) => (
|
||||
<div style={{ width: 480, height: 600 }}>
|
||||
<Story />
|
||||
</div>
|
||||
<TerminalClientSessionContext value="0123456789abcdef0123456789abcdef">
|
||||
<div style={{ width: 480, height: 600 }}>
|
||||
<Story />
|
||||
</div>
|
||||
</TerminalClientSessionContext>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof TerminalPanel>;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "#/modules/terminal/WorkspaceTerminal";
|
||||
import { WorkspaceTerminalAlerts } from "#/modules/terminal/WorkspaceTerminalAlerts";
|
||||
import { openMaybePortForwardedURL } from "#/utils/portForward";
|
||||
import { useTerminalClientSessionId } from "../context/TerminalClientSessionContext";
|
||||
|
||||
/** Promote a freshly created terminal tab after this delay if no output has painted. */
|
||||
const READY_FALLBACK_MS = 100;
|
||||
@@ -55,6 +56,10 @@ export const TerminalPanel: FC<TerminalPanelProps> = ({
|
||||
const { proxy } = useProxy();
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const terminalRef = useRef<WorkspaceTerminalHandle>(null);
|
||||
// Shared by every terminal in this chat and stable across remounts, so
|
||||
// reattaching to a live PTY keeps the same session ID while a reload or
|
||||
// switching chats starts a new one.
|
||||
const sessionId = useTerminalClientSessionId();
|
||||
const [isWarm, setIsWarm] = useState(Boolean(isHot));
|
||||
const [connectionStatus, setConnectionStatus] =
|
||||
useState<ConnectionStatus>("initializing");
|
||||
@@ -167,6 +172,7 @@ export const TerminalPanel: FC<TerminalPanelProps> = ({
|
||||
onContentReady={signalReady}
|
||||
onError={handleTerminalError}
|
||||
reconnectionToken={reconnectionToken}
|
||||
sessionId={sessionId}
|
||||
initialCommand={initialCommand}
|
||||
baseUrl={terminalConfig.baseUrl}
|
||||
terminalFontFamily={terminalConfig.fontFamily}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
const TerminalClientSessionContext = createContext<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the client session ID for the current agent chat page visit. It is
|
||||
* generated once when the page mounts and stays stable until the page unmounts
|
||||
* (navigating to another chat, leaving the page, or reloading), independent of
|
||||
* any terminal's reconnection token. Must be used within a
|
||||
* TerminalClientSessionContext provider.
|
||||
*/
|
||||
export const useTerminalClientSessionId = (): string => {
|
||||
const sessionId = useContext(TerminalClientSessionContext);
|
||||
if (sessionId === undefined) {
|
||||
throw new Error(
|
||||
"useTerminalClientSessionId must be used within a TerminalClientSessionContext provider",
|
||||
);
|
||||
}
|
||||
return sessionId;
|
||||
};
|
||||
|
||||
export { TerminalClientSessionContext };
|
||||
@@ -16,8 +16,13 @@ import TerminalPage from "./TerminalPage";
|
||||
|
||||
const reconnectToken = "terminal-page-test-reconnect-token";
|
||||
|
||||
const { mockSessionId } = vi.hoisted(() => ({
|
||||
mockSessionId: "0123456789abcdef0123456789abcdef",
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/random", () => ({
|
||||
generateUUID: () => "terminal-page-test-reconnect-token",
|
||||
generateConnectionSessionId: () => mockSessionId,
|
||||
}));
|
||||
vi.stubGlobal("jest", vi);
|
||||
await import("jest-canvas-mock");
|
||||
@@ -39,7 +44,7 @@ Object.defineProperty(window, "matchMedia", {
|
||||
|
||||
const createWorkspaceTerminalWebSocket = () => {
|
||||
const websocketProtocol = location.protocol === "https:" ? "wss" : "ws";
|
||||
const websocketUrl = `${websocketProtocol}://${location.host}/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty?reconnect=${reconnectToken}&height=24&width=80`;
|
||||
const websocketUrl = `${websocketProtocol}://${location.host}/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty?reconnect=${reconnectToken}&client_session_id=${mockSessionId}&height=24&width=80`;
|
||||
|
||||
return new WS(websocketUrl);
|
||||
};
|
||||
@@ -220,7 +225,7 @@ describe("TerminalPage", () => {
|
||||
const websocketProtocol =
|
||||
window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WS(
|
||||
`${websocketProtocol}://${window.location.host}/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty?reconnect=${reconnectToken}&command=echo+hello&height=24&width=80`,
|
||||
`${websocketProtocol}://${window.location.host}/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty?reconnect=${reconnectToken}&client_session_id=${mockSessionId}&command=echo+hello&height=24&width=80`,
|
||||
);
|
||||
renderTerminalRaw(
|
||||
`/${MockUserOwner.username}/${MockWorkspace.name}/terminal?command=echo+hello`,
|
||||
@@ -356,7 +361,7 @@ describe("TerminalPage", () => {
|
||||
const websocketProtocol =
|
||||
window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WS(
|
||||
`${websocketProtocol}://${window.location.host}/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty?reconnect=${reconnectToken}&command=echo+trusted&height=24&width=80`,
|
||||
`${websocketProtocol}://${window.location.host}/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty?reconnect=${reconnectToken}&client_session_id=${mockSessionId}&command=echo+trusted&height=24&width=80`,
|
||||
);
|
||||
await renderTerminal(
|
||||
`/${MockUserOwner.username}/${MockWorkspace.name}/terminal?app=my-app`,
|
||||
|
||||
@@ -27,7 +27,7 @@ import { WorkspaceTerminalAlerts } from "#/modules/terminal/WorkspaceTerminalAle
|
||||
import themes from "#/theme";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import { openMaybePortForwardedURL } from "#/utils/portForward";
|
||||
import { generateUUID } from "#/utils/random";
|
||||
import { generateConnectionSessionId, generateUUID } from "#/utils/random";
|
||||
import { getMatchingAgentOrFirst } from "#/utils/workspace";
|
||||
import { TerminalCommandConsentDialog } from "./TerminalCommandConsentDialog";
|
||||
|
||||
@@ -50,6 +50,10 @@ const TerminalPage: FC = () => {
|
||||
// a terminal session. It's generated by the client to reduce
|
||||
// a round-trip, and must be a UUIDv4.
|
||||
const reconnectionToken = searchParams.get("reconnect") ?? generateUUID();
|
||||
// The session ID correlates all logs, requests, and telemetry for this
|
||||
// terminal session. Unlike the reconnection token, it is not persisted in
|
||||
// the URL: a new page load (including a reload) is a new session.
|
||||
const [sessionId] = useState(() => generateConnectionSessionId());
|
||||
const command = searchParams.get("command") || undefined;
|
||||
const appSlug = searchParams.get("app") || undefined;
|
||||
const containerName = searchParams.get("container") || undefined;
|
||||
@@ -111,9 +115,15 @@ const TerminalPage: FC = () => {
|
||||
},
|
||||
[workspaceAgent, workspace.data, username, proxy.preferredWildcardHostname],
|
||||
);
|
||||
const handleTerminalError = useCallback((error: Error) => {
|
||||
console.error("WebSocket failed:", error);
|
||||
}, []);
|
||||
const handleTerminalError = useCallback(
|
||||
(error: Error) => {
|
||||
console.error("WebSocket failed:", {
|
||||
client_session_id: sessionId,
|
||||
error,
|
||||
});
|
||||
},
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const appearanceSettingsQuery = useQuery(
|
||||
@@ -176,6 +186,7 @@ const TerminalPage: FC = () => {
|
||||
onStatusChange={setConnectionStatus}
|
||||
onError={handleTerminalError}
|
||||
reconnectionToken={reconnectionToken}
|
||||
sessionId={sessionId}
|
||||
baseUrl={terminalConfig.baseUrl}
|
||||
terminalFontFamily={terminalConfig.fontFamily}
|
||||
renderer={terminalConfig.renderer}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { terminalWebsocketUrl } from "./terminal";
|
||||
|
||||
describe("terminalWebsocketUrl", () => {
|
||||
it("includes the client_session_id query parameter", async () => {
|
||||
const url = await terminalWebsocketUrl(
|
||||
undefined,
|
||||
"reconnect-token",
|
||||
"agent-id",
|
||||
undefined,
|
||||
24,
|
||||
80,
|
||||
undefined,
|
||||
undefined,
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
);
|
||||
|
||||
const parsed = new URL(url);
|
||||
expect(parsed.searchParams.get("client_session_id")).toBe(
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
);
|
||||
expect(parsed.searchParams.get("reconnect")).toBe("reconnect-token");
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,10 @@ export const terminalWebsocketUrl = async (
|
||||
width: number,
|
||||
containerName: string | undefined,
|
||||
containerUser: string | undefined,
|
||||
sessionId: string,
|
||||
): Promise<string> => {
|
||||
const query = new URLSearchParams({ reconnect });
|
||||
query.set("client_session_id", sessionId);
|
||||
if (command) {
|
||||
query.set("command", command);
|
||||
}
|
||||
@@ -38,10 +40,13 @@ export const terminalWebsocketUrl = async (
|
||||
}
|
||||
|
||||
// Do ticket issuance and set the query parameter.
|
||||
const tokenRes = await API.issueReconnectingPTYSignedToken({
|
||||
url: url.toString(),
|
||||
agentID: agentId,
|
||||
});
|
||||
const tokenRes = await API.issueReconnectingPTYSignedToken(
|
||||
{
|
||||
url: url.toString(),
|
||||
agentID: agentId,
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
query.set("coder_signed_app_token_23db1dde", tokenRes.signed_token);
|
||||
url.search = `?${query.toString()}`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user