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..896f2b575360 --- /dev/null +++ b/apps/web/src/addons/terminal-shell/terminal-shell.css @@ -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; + } +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 46ed051154a6..2efebdad6b96 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -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, @@ -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; @@ -6568,7 +6583,7 @@ function ChatViewContent(props: ChatViewProps) { composerBannerItems.length > 0 || Boolean(threadSyncPhase && !activeEnvironmentUnavailable); return ( -
+
{rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
+ {!isDraftHeroState ? ( + + ) : null} {isDraftHeroState ? (
. Listeners go -// on the whole card (no dedicated handle): the pointer sensor's distance +// Subset of useSortable applied to a thread row's root
  • . Listeners go +// on the whole row (no dedicated handle): the pointer sensor's distance // constraint keeps plain clicks working, and we skip dnd-kit's aria // attributes since there is no keyboard sensor and the card body already // carries its own button semantics. -type SortablePinnedRowBag = Pick< +type SortableThreadRowBag = Pick< ReturnType, "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" >; -function SortablePinnedThreadRow(props: { +function SortableThreadRow(props: { id: string; - children: (bag: SortablePinnedRowBag) => ReactNode; + children: (bag: SortableThreadRowBag) => ReactNode; }) { const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: props.id, @@ -705,10 +705,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // rows. The marker can unpin the thread when the server supports pinning. pinningSupported: boolean; isPinned: boolean; - // Present only on pinned cards whose server supports reordering: dnd-kit - // sortable bag applied to the card root so the whole card drags (the - // pointer sensor's distance constraint keeps plain clicks working). - sortable?: SortablePinnedRowBag | undefined; + // Present only on rows that can be reordered: dnd-kit applies the sortable + // bag to the card root so the whole card drags (the pointer sensor's + // distance constraint keeps plain clicks working). + sortable?: SortableThreadRowBag | undefined; // Compact wake countdown ("2h") for rows in the snoozed shelf. snoozeWakeLabelText: string | null; // When a snooze ended (timer or early wake); drives the Woke pill until @@ -1707,6 +1707,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { export default function Sidebar() { const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); + const threadOrder = useUiStateStore((store) => store.threadOrder); + const reorderThreadOrder = useUiStateStore((store) => store.reorderThreads); const threads = useThreadShells(); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); @@ -2564,6 +2566,29 @@ export default function Sidebar() { override holds until all of them appear in canonical state. */ readonly assignedKeys: ReadonlyMap; } | null>(null); + const activeThreadKeys = useMemo( + () => + activeThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + [activeThreads], + ); + const currentActiveThreadOrder = useMemo(() => { + const activeKeys = new Set(activeThreadKeys); + const savedOrder = threadOrder.filter((threadKey) => activeKeys.has(threadKey)); + const savedKeys = new Set(savedOrder); + const newThreadKeys = activeThreadKeys.filter((threadKey) => !savedKeys.has(threadKey)); + return [...newThreadKeys, ...savedOrder]; + }, [activeThreadKeys, threadOrder]); + const orderedActiveThreads = useMemo( + () => + orderItemsByPreferredIds({ + items: activeThreads, + preferredIds: currentActiveThreadOrder, + getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }), + [activeThreads, currentActiveThreadOrder], + ); const orderedPinnedThreads = useMemo(() => { if (optimisticPinnedOrder === null) return pinnedThreads; return orderItemsByPreferredIds({ @@ -2717,6 +2742,24 @@ export default function Sidebar() { }, [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys], ); + const threadDndSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + ); + const handleActiveThreadDragEnd = useCallback( + (event: DragEndEvent) => { + const activeKey = String(event.active.id); + const overKey = event.over === null ? null : String(event.over.id); + if (overKey === null || activeKey === overKey) return; + if ( + !currentActiveThreadOrder.includes(activeKey) || + !currentActiveThreadOrder.includes(overKey) + ) { + return; + } + reorderThreadOrder(currentActiveThreadOrder, [activeKey], [overKey]); + }, + [currentActiveThreadOrder, reorderThreadOrder], + ); // One snooze per thread at a time — same double-dispatch guard as settle. const snoozingThreadKeysRef = useRef(new Set()); const performSnooze = useCallback( @@ -3640,7 +3683,7 @@ export default function Sidebar() { const renderThreadRow = ( thread: EnvironmentThreadShell, section: "pinned" | "active" | "snoozed" | "settled", - sortable?: SortablePinnedRowBag, + sortable?: SortableThreadRowBag, ) => { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), @@ -3788,9 +3831,9 @@ export default function Sidebar() { return renderThreadRow(thread, "pinned"); } return ( - + {(bag) => renderThreadRow(thread, "pinned", bag)} - + ); })} @@ -3809,8 +3852,39 @@ export default function Sidebar() { />, ); } - for (const thread of activeThreads) { - items.push(renderThreadRow(thread, "active")); + if (orderedActiveThreads.length > 0) { + items.push( +
  • + + +
      + {orderedActiveThreads.map((thread) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + return ( + + {(bag) => renderThreadRow(thread, "active", bag)} + + ); + })} +
    +
    +
    +
  • , + ); } // Snoozed shelf: between the inbox and Settled — out of the // way, never gone. The header always renders while anything diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f29d6c2b4f6a..aee5bdecc89a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -227,6 +227,7 @@ import { toastManager } from "../ui/toast"; import { BotIcon, CircleAlertIcon, + PaperclipIcon, PencilRulerIcon, type LucideIcon, LockIcon, @@ -1034,6 +1035,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) * next draft. */ const pendingImageCompressionsRef = useRef>(new Map()); + const imageFileInputRef = useRef(null); // ------------------------------------------------------------------ // Derived: composer send state @@ -3309,6 +3311,40 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )} >
    + { + const files = Array.from(event.currentTarget.files ?? []); + // Clear the input so choosing the same image again still + // emits a change event after it has been removed. + event.currentTarget.value = ""; + if (files.length === 0) return; + void addComposerImages(files); + focusComposer(); + }} + /> + 0 + } + onClick={() => imageFileInputRef.current?.click()} + > + + Attach + + {noProviderAvailable ? (