diff --git a/apps/web/src/addons/terminal-shell/index.test.tsx b/apps/web/src/addons/terminal-shell/index.test.tsx new file mode 100644 index 000000000000..4db53fda219c --- /dev/null +++ b/apps/web/src/addons/terminal-shell/index.test.tsx @@ -0,0 +1,32 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { TerminalShellStatus, terminalShellRootProps } from "."; + +describe("terminal shell addon", () => { + it("exposes a stable host hook for its scoped styles", () => { + expect(terminalShellRootProps["data-terminal-shell"]).toBe("true"); + expect(terminalShellRootProps.className).toContain("terminal-shell"); + }); + + it("renders ready status without working-only controls", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Ready"); + expect(markup).not.toContain("esc to interrupt"); + expect(markup).not.toContain("terminal active"); + }); + + it("renders working status and pluralizes active terminals", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-terminal-shell-status-state="working"'); + expect(markup).toContain("Running command"); + expect(markup).toContain("esc to interrupt"); + expect(markup).toContain("2 terminals active"); + }); +}); diff --git a/apps/web/src/addons/terminal-shell/index.tsx b/apps/web/src/addons/terminal-shell/index.tsx new file mode 100644 index 000000000000..a797aa14c9e3 --- /dev/null +++ b/apps/web/src/addons/terminal-shell/index.tsx @@ -0,0 +1,34 @@ +export const terminalShellRootProps = { + className: "terminal-shell relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background", + "data-terminal-shell": "true", +} as const; + +export type TerminalShellStatusProps = { + readonly isWorking: boolean; + readonly workingStepLabel: string | null; + readonly activeTerminalCount: number; +}; + +export function TerminalShellStatus({ + isWorking, + workingStepLabel, + activeTerminalCount, +}: TerminalShellStatusProps) { + return ( +
+ + {isWorking ? (workingStepLabel ?? "Working") : "Ready"} + + {isWorking ? : null} + {activeTerminalCount > 0 ? ( + + · {activeTerminalCount} terminal{activeTerminalCount === 1 ? "" : "s"} active + + ) : null} +
+ ); +} diff --git a/apps/web/src/addons/terminal-shell/terminal-shell.css b/apps/web/src/addons/terminal-shell/terminal-shell.css new file mode 100644 index 000000000000..63be7c12c8e7 --- /dev/null +++ b/apps/web/src/addons/terminal-shell/terminal-shell.css @@ -0,0 +1,198 @@ +/* Fork-local presentation addon. Keep its selectors scoped to the host hook so + upstream chat markup remains the source of truth. */ +[data-terminal-shell="true"] { + --terminal-shell-background: #11161d; + --terminal-shell-surface: #1b2230; + --terminal-shell-border: rgb(255 255 255 / 9%); + --terminal-shell-muted: #8f9aa8; + --terminal-shell-foreground: #e6e9ed; + --terminal-shell-accent: #52e7d1; + --terminal-shell-selection: rgb(82 231 209 / 18%); + background: var(--terminal-shell-background); + color: var(--terminal-shell-foreground); + font-family: var(--font-mono); +} + +[data-terminal-shell="true"] [data-chat-header] { + min-height: 2.75rem; + border-bottom: 1px solid var(--terminal-shell-border); + background: var(--terminal-shell-background); + color: var(--terminal-shell-muted); + font-family: var(--font-mono); +} + +[data-terminal-shell="true"] [data-chat-header] h2, +[data-terminal-shell="true"] [data-chat-header] button, +[data-terminal-shell="true"] [data-chat-header] [data-project-favicon] { + font-family: var(--font-mono); +} + +[data-terminal-shell="true"] [data-chat-workspace-drop-target="true"] { + background: var(--terminal-shell-background); +} + +[data-terminal-shell="true"] [data-timeline-root="true"] { + max-width: none; + padding-inline: clamp(1rem, 4vw, 4rem); + font-family: var(--font-mono); + font-size: 0.9375rem; + line-height: 1.55; +} + +[data-terminal-shell="true"] [data-timeline-row-kind="message"] { + padding-block: 0.35rem; +} + +[data-terminal-shell="true"] [data-message-role="assistant"] > div { + padding-inline: 0; +} + +[data-terminal-shell="true"] [data-message-role="user"] > .group { + align-items: flex-start; + gap: 0; +} + +[data-terminal-shell="true"] [data-message-role="user"] > .group > div:first-child { + max-width: none; + border: 0; + border-radius: 0; + background: var(--terminal-shell-surface); + padding: 0.5rem 0.625rem; + color: var(--terminal-shell-foreground); +} + +[data-terminal-shell="true"] [data-message-role="user"] > .group > div:first-child::before { + margin-inline-end: 0.375rem; + color: var(--terminal-shell-accent); + content: ">"; + line-height: 1.55; +} + +[data-terminal-shell="true"] + [data-message-role="user"] + > .group + > div:first-child + > div:first-child { + border: 0; + border-radius: 0; + background: transparent; + padding: 0; +} + +[data-terminal-shell="true"] [data-message-role="user"] [data-user-message-footer="true"] { + max-width: none; + justify-content: flex-start; + padding-inline-start: 1.5rem; + color: var(--terminal-shell-muted); +} + +[data-terminal-shell="true"] [data-message-role="assistant"] a, +[data-terminal-shell="true"] [data-message-role="assistant"] code, +[data-terminal-shell="true"] [data-message-role="user"] code { + color: var(--terminal-shell-accent); +} + +[data-terminal-shell="true"] [data-message-role="assistant"] a { + text-decoration-color: color-mix(in srgb, var(--terminal-shell-accent) 65%, transparent); +} + +[data-terminal-shell="true"] [data-message-role="assistant"] pre, +[data-terminal-shell="true"] [data-message-role="user"] pre { + border-color: var(--terminal-shell-border); + border-radius: 2px; + background: rgb(0 0 0 / 22%); +} + +[data-terminal-shell="true"] .chat-composer-glass-shell { + --chat-composer-glass-surface: var(--terminal-shell-surface); + --chat-composer-outline: var(--terminal-shell-border); + max-width: none; +} + +[data-terminal-shell="true"] .chat-composer-glass-shell::before, +[data-terminal-shell="true"] .chat-composer-glass-host::after { + border-radius: 3px; +} + +[data-terminal-shell="true"] .chat-composer-glass-shell::before { + background: var(--terminal-shell-surface); + box-shadow: 0 -14px 34px -28px rgb(0 0 0 / 80%); +} + +[data-terminal-shell="true"] .chat-composer-glass-host, +[data-terminal-shell="true"] [data-chat-composer-main-surface="true"], +[data-terminal-shell="true"] [data-chat-composer-surface="true"] { + border-radius: 3px; +} + +[data-terminal-shell="true"] [data-chat-composer-form="true"] { + max-width: none; +} + +[data-terminal-shell="true"] [data-chat-composer-main-surface="true"] { + border: 1px solid var(--terminal-shell-border); + background: var(--terminal-shell-surface); + box-shadow: none; +} + +[data-terminal-shell="true"] [data-chat-composer-surface="true"] { + background: transparent; +} + +[data-terminal-shell="true"] [data-testid="composer-editor"] { + min-height: 3.5rem; + padding-block: 0.15rem; + font-family: var(--font-mono); + font-size: 0.9375rem; + line-height: 1.55; + caret-color: var(--terminal-shell-accent); +} + +[data-terminal-shell="true"] [data-testid="composer-editor"]::selection { + background: var(--terminal-shell-selection); +} + +[data-terminal-shell="true"] [data-chat-composer-main-surface="true"] button:hover { + background: rgb(255 255 255 / 7%); +} + +[data-terminal-shell="true"] [data-chat-composer-overlay] { + background: linear-gradient(to top, var(--terminal-shell-background) 0%, transparent 100%); + padding-top: 1.5rem; +} + +[data-terminal-shell="true"] [data-terminal-shell-status="true"] { + font-family: var(--font-mono); + letter-spacing: 0.01em; +} + +[data-terminal-shell="true"] [data-terminal-shell-status-state] { + color: var(--terminal-shell-muted); +} + +[data-terminal-shell="true"] [data-terminal-shell-status-state]::before { + display: inline-block; + width: 0.5rem; + height: 0.5rem; + margin-inline-end: 0.5rem; + border-radius: 999px; + background: var(--terminal-shell-muted); + content: ""; + vertical-align: 0.03em; +} + +[data-terminal-shell="true"] [data-terminal-shell-status-state="working"] { + color: var(--terminal-shell-accent); +} + +[data-terminal-shell="true"] [data-terminal-shell-status-state="working"]::before { + background: var(--terminal-shell-accent); + box-shadow: 0 0 0 3px rgb(82 231 209 / 10%); +} + +@media (max-width: 639px) { + [data-terminal-shell="true"] [data-timeline-root="true"] { + padding-inline: 1rem; + font-size: 0.875rem; + } +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 07a0d0c28254..1bb9c4922851 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -248,6 +248,7 @@ import { } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; +import { TerminalShellStatus, terminalShellRootProps } from "../addons/terminal-shell"; import { requestOlderThreadTurns, threadHasOlderTurns, @@ -6440,10 +6441,7 @@ function ChatViewContent(props: ChatViewProps) { composerBannerItems.length > 0 || Boolean(threadSyncPhase && !activeEnvironmentUnavailable); return ( -
+
{rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
{!isDraftHeroState ? ( -
- - {isWorking ? (workingStepLabel ?? "Working") : "Ready"} - - {isWorking ? : null} - {terminalUiState.terminalIds.length > 0 ? ( - - · {terminalUiState.terminalIds.length} terminal - {terminalUiState.terminalIds.length === 1 ? "" : "s"} active - - ) : null} -
+ ) : null} {isDraftHeroState ? (
diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 0d8bc4c77c0e..38c605fe746b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1667,207 +1667,6 @@ html[data-theme-id="t3-chat"] } } -/* Terminal shell - The chat remains the same provider-backed workspace, but this presentation - makes the transcript feel like a live terminal session: the output owns the - canvas, prompts are flat and wide, and status/color are intentionally quiet. */ -[data-terminal-shell="true"] { - --terminal-shell-background: #11161d; - --terminal-shell-surface: #1b2230; - --terminal-shell-border: rgb(255 255 255 / 9%); - --terminal-shell-muted: #8f9aa8; - --terminal-shell-foreground: #e6e9ed; - --terminal-shell-accent: #52e7d1; - --terminal-shell-selection: rgb(82 231 209 / 18%); - background: var(--terminal-shell-background); - color: var(--terminal-shell-foreground); - font-family: var(--font-mono); -} - -[data-terminal-shell="true"] [data-chat-header] { - min-height: 2.75rem; - border-bottom: 1px solid var(--terminal-shell-border); - background: var(--terminal-shell-background); - color: var(--terminal-shell-muted); - font-family: var(--font-mono); -} - -[data-terminal-shell="true"] [data-chat-header] h2, -[data-terminal-shell="true"] [data-chat-header] button, -[data-terminal-shell="true"] [data-chat-header] [data-project-favicon] { - font-family: var(--font-mono); -} - -[data-terminal-shell="true"] [data-chat-workspace-drop-target="true"] { - background: var(--terminal-shell-background); -} - -[data-terminal-shell="true"] [data-timeline-root="true"] { - max-width: none; - padding-inline: clamp(1rem, 4vw, 4rem); - font-family: var(--font-mono); - font-size: 0.9375rem; - line-height: 1.55; -} - -[data-terminal-shell="true"] [data-timeline-row-kind="message"] { - padding-block: 0.35rem; -} - -[data-terminal-shell="true"] [data-message-role="assistant"] > div { - padding-inline: 0; -} - -[data-terminal-shell="true"] [data-message-role="user"] > .group { - align-items: flex-start; - gap: 0; -} - -[data-terminal-shell="true"] [data-message-role="user"] > .group > div:first-child { - max-width: none; - border: 0; - border-radius: 0; - background: var(--terminal-shell-surface); - padding: 0.5rem 0.625rem; - color: var(--terminal-shell-foreground); -} - -[data-terminal-shell="true"] [data-message-role="user"] > .group > div:first-child::before { - margin-inline-end: 0.375rem; - color: var(--terminal-shell-accent); - content: ">"; - line-height: 1.55; -} - -[data-terminal-shell="true"] - [data-message-role="user"] - > .group - > div:first-child - > div:first-child { - border: 0; - border-radius: 0; - background: transparent; - padding: 0; -} - -[data-terminal-shell="true"] [data-message-role="user"] [data-user-message-footer="true"] { - max-width: none; - justify-content: flex-start; - padding-inline-start: 1.5rem; - color: var(--terminal-shell-muted); -} - -[data-terminal-shell="true"] [data-message-role="assistant"] a, -[data-terminal-shell="true"] [data-message-role="assistant"] code, -[data-terminal-shell="true"] [data-message-role="user"] code { - color: var(--terminal-shell-accent); -} - -[data-terminal-shell="true"] [data-message-role="assistant"] a { - text-decoration-color: color-mix(in srgb, var(--terminal-shell-accent) 65%, transparent); -} - -[data-terminal-shell="true"] [data-message-role="assistant"] pre, -[data-terminal-shell="true"] [data-message-role="user"] pre { - border-color: var(--terminal-shell-border); - border-radius: 2px; - background: rgb(0 0 0 / 22%); -} - -[data-terminal-shell="true"] .chat-composer-glass-shell { - --chat-composer-glass-surface: var(--terminal-shell-surface); - --chat-composer-outline: var(--terminal-shell-border); - max-width: none; -} - -[data-terminal-shell="true"] .chat-composer-glass-shell::before, -[data-terminal-shell="true"] .chat-composer-glass-host::after { - border-radius: 3px; -} - -[data-terminal-shell="true"] .chat-composer-glass-shell::before { - background: var(--terminal-shell-surface); - box-shadow: 0 -14px 34px -28px rgb(0 0 0 / 80%); -} - -[data-terminal-shell="true"] .chat-composer-glass-host, -[data-terminal-shell="true"] [data-chat-composer-main-surface="true"], -[data-terminal-shell="true"] [data-chat-composer-surface="true"] { - border-radius: 3px; -} - -[data-terminal-shell="true"] [data-chat-composer-form="true"] { - max-width: none; -} - -[data-terminal-shell="true"] [data-chat-composer-main-surface="true"] { - border: 1px solid var(--terminal-shell-border); - background: var(--terminal-shell-surface); - box-shadow: none; -} - -[data-terminal-shell="true"] [data-chat-composer-surface="true"] { - background: transparent; -} - -[data-terminal-shell="true"] [data-testid="composer-editor"] { - min-height: 3.5rem; - padding-block: 0.15rem; - font-family: var(--font-mono); - font-size: 0.9375rem; - line-height: 1.55; - caret-color: var(--terminal-shell-accent); -} - -[data-terminal-shell="true"] [data-testid="composer-editor"]::selection { - background: var(--terminal-shell-selection); -} - -[data-terminal-shell="true"] [data-chat-composer-main-surface="true"] button:hover { - background: rgb(255 255 255 / 7%); -} - -[data-terminal-shell="true"] [data-chat-composer-overlay] { - background: linear-gradient(to top, var(--terminal-shell-background) 0%, transparent 100%); - padding-top: 1.5rem; -} - -[data-terminal-shell="true"] [data-terminal-shell-status="true"] { - font-family: var(--font-mono); - letter-spacing: 0.01em; -} - -[data-terminal-shell="true"] [data-terminal-shell-status-state] { - color: var(--terminal-shell-muted); -} - -[data-terminal-shell="true"] [data-terminal-shell-status-state]::before { - display: inline-block; - width: 0.5rem; - height: 0.5rem; - margin-inline-end: 0.5rem; - border-radius: 999px; - background: var(--terminal-shell-muted); - content: ""; - vertical-align: 0.03em; -} - -[data-terminal-shell="true"] [data-terminal-shell-status-state="working"] { - color: var(--terminal-shell-accent); -} - -[data-terminal-shell="true"] [data-terminal-shell-status-state="working"]::before { - background: var(--terminal-shell-accent); - box-shadow: 0 0 0 3px rgb(82 231 209 / 10%); -} - -@media (max-width: 639px) { - [data-terminal-shell="true"] [data-timeline-root="true"] { - padding-inline: 1rem; - font-size: 0.875rem; - } -} - /* Theme-token dependency probes are restored synchronously, before paint. Keep transitions from observing the temporary sentinel color in between. */ html[data-theme-token-probe], diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 6eaaca6f57de..5da186b90ecf 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -6,6 +6,7 @@ import { ClerkProvider as ElectronClerkProvider } from "@clerk/electron/react"; import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; import "./index.css"; +import "./addons/terminal-shell/terminal-shell.css"; import { isElectron } from "./env"; import { ManagedRelayAuthProvider } from "./cloud/managedAuth"; diff --git a/docs/internals/fork-addons.md b/docs/internals/fork-addons.md new file mode 100644 index 000000000000..77bcc8a769af --- /dev/null +++ b/docs/internals/fork-addons.md @@ -0,0 +1,18 @@ +# Fork addons + +Fork-only product customizations live under `apps/web/src/addons/`. Each addon +owns its presentation code, styles, and focused tests. Upstream feature code +should remain unchanged unless the addon needs a small host hook. + +The terminal shell is the first addon. Its stylesheet is imported separately +from the upstream `index.css`, and `ChatView` only supplies the root props and +runtime status values that the addon cannot discover on its own. + +When updating from upstream: + +1. Keep addon files in their own commits where practical. +2. Rebase or merge upstream before resolving addon host-hook conflicts. +3. If upstream changes the chat markup targeted by an addon stylesheet, update + the addon and its tests together. +4. Do not copy addon styles back into shared stylesheets; that makes future + upstream pulls needlessly conflict-prone.