Skip to content
Open
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
32 changes: 32 additions & 0 deletions apps/web/src/addons/terminal-shell/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<TerminalShellStatus activeTerminalCount={0} isWorking={false} workingStepLabel={null} />,
);

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(
<TerminalShellStatus activeTerminalCount={2} isWorking workingStepLabel="Running command" />,
);

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");
});
});
34 changes: 34 additions & 0 deletions apps/web/src/addons/terminal-shell/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
aria-live="polite"
className="flex items-center gap-2 px-1 pb-1.5 text-[11px] text-muted-foreground"
data-terminal-shell-status="true"
>
<span data-terminal-shell-status-state={isWorking ? "working" : "ready"}>
{isWorking ? (workingStepLabel ?? "Working") : "Ready"}
</span>
{isWorking ? <span aria-hidden="true">· esc to interrupt</span> : null}
{activeTerminalCount > 0 ? (
<span>
· {activeTerminalCount} terminal{activeTerminalCount === 1 ? "" : "s"} active
</span>
) : null}
</div>
);
}
187 changes: 187 additions & 0 deletions apps/web/src/addons/terminal-shell/terminal-shell.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/* 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: transparent;
padding: 0;
color: var(--terminal-shell-foreground);
}

[data-terminal-shell="true"] [data-message-role="user"] > .group > div:first-child::before {
margin-inline-end: 0.75rem;
color: var(--terminal-shell-accent);
content: ">";
line-height: 1.55;
}

[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;
}
}
24 changes: 23 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,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,
Expand Down Expand Up @@ -4167,6 +4168,20 @@ function ChatViewContent(props: ChatViewProps) {
};
}, [activeThread?.id, timelineEntries, getActiveTimelineTurnMetrics]);

useEffect(() => {
if (!activeThread?.id || !latestTurnSettled) {
return;
}
if (timelineScrollModeRef.current !== "anchoring-new-turn") {
return;
}

// The extra tail is only useful while the first response is streaming.
// Once that turn settles, remove it and return to the real end of the chat
// so a long thread cannot leave a large empty region below its last row.
scrollToEnd();
}, [activeThread?.id, latestTurnSettled, scrollToEnd]);

useEffect(() => {
setPullRequestDialogState(null);
isAtEndRef.current = true;
Expand Down Expand Up @@ -6568,7 +6583,7 @@ function ChatViewContent(props: ChatViewProps) {
composerBannerItems.length > 0 || Boolean(threadSyncPhase && !activeEnvironmentUnavailable);

return (
<div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
<div {...terminalShellRootProps}>
{rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
<div
className={cn(
Expand Down Expand Up @@ -6728,6 +6743,13 @@ function ChatViewContent(props: ChatViewProps) {
className="w-full ps-[calc(env(safe-area-inset-left)+0.75rem)] pe-[calc(env(safe-area-inset-right)+0.75rem)] sm:ps-[calc(env(safe-area-inset-left)+1.25rem)] sm:pe-[calc(env(safe-area-inset-right)+1.25rem)]"
>
<div className="pointer-events-auto relative z-10">
{!isDraftHeroState ? (
<TerminalShellStatus
activeTerminalCount={terminalUiState.terminalIds.length}
isWorking={isWorking}
workingStepLabel={workingStepLabel}
/>
) : null}
{isDraftHeroState ? (
<div className="absolute inset-x-0 bottom-full z-0">
<div
Expand Down
Loading
Loading