diff --git a/.husky/pre-push b/.husky/pre-push index 5d3cc53411be..79db8d3e17c2 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -17,4 +17,4 @@ if (process.versions.bun !== expectedBunVersion) { console.warn(`Warning: Bun version ${process.versions.bun} differs from expected ${expectedBunVersion}`); } ' -bun typecheck +bun turbo typecheck --concurrency=3 diff --git a/docs/superpowers/plans/2026-07-22-limit-pre-push-typecheck-concurrency-plan.md b/docs/superpowers/plans/2026-07-22-limit-pre-push-typecheck-concurrency-plan.md new file mode 100644 index 000000000000..1e8de3e5ab7e --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-limit-pre-push-typecheck-concurrency-plan.md @@ -0,0 +1,79 @@ +# Limit Pre-Push Typecheck Concurrency Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Limit the repository's Husky pre-push typecheck to three parallel Turbo tasks. + +**Architecture:** Modify only the pre-push hook to call the existing Turbo `typecheck` task graph with an explicit `--concurrency=3` argument. Keep the root package script and CI behavior unchanged. + +**Tech Stack:** POSIX shell, Husky, Bun, Turborepo 2.8.13 + +## Global Constraints + +- The concurrency value is exactly `3`. +- Only `.husky/pre-push` changes during implementation. +- The root `typecheck` package script remains unchanged. +- Validation must not execute the full typecheck workload. + +--- + +### Task 1: Limit the pre-push typecheck + +**Files:** +- Modify: `.husky/pre-push:20` +- Test: static shell assertions and Turbo dry-run + +**Interfaces:** +- Consumes: the existing Turborepo `typecheck` task graph. +- Produces: a pre-push hook that schedules at most three Turbo tasks concurrently. + +- [ ] **Step 1: Run the failing static assertion** + +```bash +grep -Fx 'bun turbo typecheck --concurrency=3' .husky/pre-push +``` + +Expected: exit status `1` because the hook currently contains `bun typecheck`. + +- [ ] **Step 2: Apply the minimal implementation** + +Replace: + +```sh +bun typecheck +``` + +with: + +```sh +bun turbo typecheck --concurrency=3 +``` + +- [ ] **Step 3: Run the passing static assertions** + +```bash +grep -Fx 'bun turbo typecheck --concurrency=3' .husky/pre-push +! grep -Fx 'bun typecheck' .husky/pre-push +``` + +Expected: both assertions exit with status `0`. + +- [ ] **Step 4: Validate shell syntax and Turbo argument parsing** + +```bash +sh -n .husky/pre-push +bun turbo typecheck --concurrency=3 --dry=json >/tmp/opencode-typecheck-dry-run.json +jq -e '.tasks | type == "array"' /tmp/opencode-typecheck-dry-run.json +rm -f /tmp/opencode-typecheck-dry-run.json +``` + +Expected: all commands exit with status `0`; no `tsgo` typecheck workers are launched. + +- [ ] **Step 5: Commit the hook change** + +```bash +git add -- .husky/pre-push +git commit -m "fix: limit pre-push typecheck concurrency" -- .husky/pre-push +``` + +Expected: one commit containing only `.husky/pre-push`. diff --git a/docs/superpowers/specs/2026-07-22-limit-pre-push-typecheck-concurrency-design.md b/docs/superpowers/specs/2026-07-22-limit-pre-push-typecheck-concurrency-design.md new file mode 100644 index 000000000000..9a575d0da6c5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-limit-pre-push-typecheck-concurrency-design.md @@ -0,0 +1,31 @@ +# Limit pre-push typecheck concurrency + +## Problem + +The repository's Husky `pre-push` hook runs `bun typecheck`, which expands to `bun turbo typecheck`. On the two-CPU, 3.8 GiB server, Turbo launched ten `tsgo` workers while checking 35 packages. Those workers exhausted RAM and swap and triggered the OOM killer. + +## Decision + +Change only `.husky/pre-push` to invoke Turbo directly with a concurrency limit: + +```sh +bun turbo typecheck --concurrency=3 +``` + +This keeps the existing typecheck task graph while allowing at most three parallel tasks during a push. The global `typecheck` package script remains unchanged, so local and CI callers retain their current behavior. + +## Alternatives considered + +- Add `--concurrency=3` to the root `typecheck` package script. Rejected because it changes every caller, not only the problematic push hook. +- Set `TURBO_CONCURRENCY=3` in the hook environment. Rejected because the effective limit is less visible than an explicit CLI argument. + +## Validation + +1. Before editing, verify the hook does not contain the required limited command. +2. After editing, verify the hook contains exactly `bun turbo typecheck --concurrency=3` and no unrestricted `bun typecheck` invocation. +3. Run the hook through `sh -n`. +4. Run Turbo with `--dry` and `--concurrency=3` to validate argument parsing without starting package typechecks. + +## Out of scope + +This change does not alter systemd services, OpenCode memory limits, CI behavior, or the root `typecheck` script. diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 3872d01ae78b..3a7ace5b0221 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -630,6 +630,7 @@ export const dict = { "session.messages.loadEarlier": "Load earlier messages", "session.messages.loading": "Loading messages...", "session.messages.jumpToLatest": "Jump to latest", + "session.messages.jumpToLatestUser": "Jump to last user message", "session.context.addToContext": "Add {{selection}} to context", "session.todo.title": "Todos", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index b2f58b101b40..4c71d106dc41 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -527,6 +527,7 @@ export const dict = { "session.messages.loadEarlier": "Загрузить предыдущие сообщения", "session.messages.loading": "Загрузка сообщений...", "session.messages.jumpToLatest": "Перейти к последнему", + "session.messages.jumpToLatestUser": "Перейти к последнему сообщению пользователя", "session.context.addToContext": "Добавить {{selection}} в контекст", "session.todo.title": "Задачи", diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 42712732bf0d..b04d8ed38e4d 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -82,6 +82,14 @@ type TimelineRowByTag = Extract }>() +const messageDisplayOptions = { + metadata: { + messageTimestamp: true, + toolTimestamp: true, + toolDuration: true, + toolStatus: true, + }, +} as const const taskDescription = (part: PartType, sessionID: string) => { if (part.type !== "tool" || part.tool !== "task") return @@ -464,6 +472,15 @@ export function MessageTimeline(props: { () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), ) const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key as string)) + const handleScrollToLastUser = () => { + const lastUser = props.userMessages.at(-1) + if (!lastUser) return + const index = messageRowIndex().get(lastUser.id) + if (index !== undefined) { + virtualizer.scrollToIndex(index, { align: "start" }) + } + } + createEffect(() => { props.setRevealMessage?.((id) => { const index = messageRowIndex().get(id) @@ -960,6 +977,7 @@ export function MessageTimeline(props: { message={message()} showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)} turnDurationMs={turnDurationMs(row().userMessageID)} + displayOptions={messageDisplayOptions} defaultOpen={defaultOpen()} toolOpen={toolOpen[part().id] ?? defaultOpen()} onToolOpenChange={(open) => setToolOpen(part().id, open)} @@ -1224,7 +1242,7 @@ export function MessageTimeline(props: { return (
+ 0}> + +
+ +
+ + } + > + +
+
{ export interface BasicToolProps { icon: IconProps["name"] trigger: TriggerTitle | JSX.Element | ((open: Accessor) => JSX.Element) + meta?: string children?: JSX.Element status?: string hideDetails?: boolean @@ -244,6 +245,11 @@ export function BasicTool(props: BasicToolProps) { {props.trigger as JSX.Element}
+ + + {props.meta} + +
diff --git a/packages/session-ui/src/components/message-actions.ts b/packages/session-ui/src/components/message-actions.ts new file mode 100644 index 000000000000..ded302e0a9c1 --- /dev/null +++ b/packages/session-ui/src/components/message-actions.ts @@ -0,0 +1,8 @@ +export function handleCopyResponseClick(event: Pick, copy: () => void) { + event.stopPropagation() + copy() +} + +export function formatMessageStamp(locale: string, value: number) { + return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(value) +} diff --git a/packages/session-ui/src/components/message-options.ts b/packages/session-ui/src/components/message-options.ts new file mode 100644 index 000000000000..b9eab1e672e5 --- /dev/null +++ b/packages/session-ui/src/components/message-options.ts @@ -0,0 +1,10 @@ +export type MessageMetadataDisplayOptions = { + readonly messageTimestamp?: boolean + readonly toolTimestamp?: boolean + readonly toolDuration?: boolean + readonly toolStatus?: boolean +} + +export type MessageDisplayOptions = { + readonly metadata?: MessageMetadataDisplayOptions +} diff --git a/packages/session-ui/src/components/message-part.test.ts b/packages/session-ui/src/components/message-part.test.ts index 25dcbae6b404..5aab2a7f1a8d 100644 --- a/packages/session-ui/src/components/message-part.test.ts +++ b/packages/session-ui/src/components/message-part.test.ts @@ -1,5 +1,19 @@ import { describe, expect, test } from "bun:test" +import type { UiI18n } from "@opencode-ai/ui/context/i18n" +import { formatMessageStamp, handleCopyResponseClick } from "./message-actions" import { readPartText } from "./message-part-text" +import { toolMeta } from "./tool-meta" + +const i18n: UiI18n = { + locale: () => "en-US", + t: (key, values) => { + if (key === "ui.message.duration.seconds") return `${values?.count}s` + if (key === "ui.message.duration.minutesSeconds") return `${values?.minutes}m ${values?.seconds}s` + if (key === "ui.message.interrupted") return "Interrupted" + if (key === "ui.toolErrorCard.failed") return "Failed" + return key + }, +} describe("readPartText", () => { test("returns empty string when accum is undefined and part text is undefined", () => { @@ -26,3 +40,158 @@ describe("readPartText", () => { expect(readPartText(undefined, { id: "part_1", text: "\n body \n" })).toBe("body") }) }) + +describe("toolMeta", () => { + test("returns empty meta when display options are omitted", () => { + expect( + toolMeta(i18n, { + status: "completed", + start: Date.UTC(2026, 5, 28, 14, 3, 0), + end: Date.UTC(2026, 5, 28, 14, 3, 5), + now: Date.UTC(2026, 5, 28, 14, 3, 8), + }), + ).toBe("") + }) + + test("formats completed tool start time and duration", () => { + expect( + toolMeta( + i18n, + { + status: "completed", + start: Date.UTC(2026, 5, 28, 14, 3, 0), + end: Date.UTC(2026, 5, 28, 14, 3, 5), + now: Date.UTC(2026, 5, 28, 14, 3, 8), + }, + { toolTimestamp: true, toolDuration: true }, + ), + ).toBe("2:03 PM · 5s") + }) + + test("formats completed tool duration without start time when only duration is enabled", () => { + expect( + toolMeta( + i18n, + { + status: "completed", + start: Date.UTC(2026, 5, 28, 14, 3, 0), + end: Date.UTC(2026, 5, 28, 14, 3, 5), + now: Date.UTC(2026, 5, 28, 14, 3, 8), + }, + { toolDuration: true }, + ), + ).toBe("5s") + }) + + test("formats running tool start time and live duration", () => { + expect( + toolMeta( + i18n, + { + status: "running", + start: Date.UTC(2026, 5, 28, 14, 3, 0), + now: Date.UTC(2026, 5, 28, 14, 3, 7), + }, + { toolTimestamp: true, toolDuration: true }, + ), + ).toBe("2:03 PM · 7s") + }) + + test("shows interrupted state without duration", () => { + expect( + toolMeta( + i18n, + { + status: "interrupted", + start: Date.UTC(2026, 5, 28, 14, 3, 0), + now: Date.UTC(2026, 5, 28, 14, 3, 7), + }, + { toolTimestamp: true, toolStatus: true }, + ), + ).toBe("2:03 PM · Interrupted") + }) + + test("hides interrupted state when status display is disabled", () => { + expect( + toolMeta( + i18n, + { + status: "interrupted", + start: Date.UTC(2026, 5, 28, 14, 3, 0), + now: Date.UTC(2026, 5, 28, 14, 3, 7), + }, + { toolTimestamp: true }, + ), + ).toBe("2:03 PM") + }) + + test("returns empty meta for pending tool without start time", () => { + expect( + toolMeta( + i18n, + { + status: "pending", + now: Date.UTC(2026, 5, 28, 14, 3, 7), + }, + { toolTimestamp: true, toolDuration: true, toolStatus: true }, + ), + ).toBe("") + }) + + test("formats error tool start time and failed state", () => { + expect( + toolMeta( + i18n, + { + status: "error", + start: Date.UTC(2026, 5, 28, 14, 3, 0), + end: Date.UTC(2026, 5, 28, 14, 3, 5), + now: Date.UTC(2026, 5, 28, 14, 3, 8), + }, + { toolTimestamp: true, toolStatus: true }, + ), + ).toBe("2:03 PM · Failed") + }) + + test("formats minute durations", () => { + expect( + toolMeta( + i18n, + { + status: "completed", + start: Date.UTC(2026, 5, 28, 14, 3, 0), + end: Date.UTC(2026, 5, 28, 14, 4, 5), + now: Date.UTC(2026, 5, 28, 14, 4, 8), + }, + { toolTimestamp: true, toolDuration: true }, + ), + ).toBe("2:03 PM · 1m 5s") + }) +}) + +describe("handleCopyResponseClick", () => { + test("stops timeline click propagation before copying the response", () => { + let stopped = false + let copied = false + + handleCopyResponseClick( + { + stopPropagation: () => { + stopped = true + }, + }, + () => { + copied = stopped + }, + ) + + expect(stopped).toBe(true) + expect(copied).toBe(true) + }) +}) + +describe("formatMessageStamp", () => { + test("formats message date and time", () => { + expect(formatMessageStamp("en-US", Date.UTC(2026, 5, 28, 14, 3, 0))).toBe("Jun 28, 2026, 2:03 PM") + }) +}) diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 4af05c2584a8..e897afe65ddf 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -58,6 +58,50 @@ import { animate } from "motion" import { useLocation } from "@solidjs/router" import { attached, inline, kind } from "./message-file" import { readPartText } from "./message-part-text" +import { toolMeta, type ToolMetaInput } from "./tool-meta" +import { formatMessageStamp, handleCopyResponseClick } from "./message-actions" +import type { MessageDisplayOptions } from "./message-options" + +function toolMetaInput(part: ToolPart, now: number): ToolMetaInput { + const state = part.state + if (state.status === "pending") return { status: state.status, now } + if (state.status === "running") return { status: state.status, start: state.time.start, now } + if (state.status === "completed") return { status: state.status, start: state.time.start, end: state.time.end, now } + if (state.metadata?.interrupted === true) return { status: "interrupted" as const, start: state.time.start, now } + return { status: state.status, start: state.time.start, end: state.time.end, now } +} + +function toolPartMetadata(part: ToolPart, fallback: Record) { + const state = part.state + if (state.status === "pending") return fallback + return state.metadata ?? fallback +} + +function toolPartError(part: ToolPart) { + const state = part.state + if (state.status !== "error") return undefined + return state.error +} + +function toolPartOutput(part: ToolPart) { + const state = part.state + if (state.status !== "completed") return undefined + return state.output +} + +function createToolMeta(part: () => ToolPart, i18n: UiI18n, displayOptions?: () => MessageDisplayOptions | undefined) { + const [now, setNow] = createSignal(Date.now()) + + createEffect(() => { + const status = part().state.status + if (status !== "pending" && status !== "running") return + setNow(Date.now()) + const interval = setInterval(() => setNow(Date.now()), 1000) + onCleanup(() => clearInterval(interval)) + }) + + return createMemo(() => toolMeta(i18n, toolMetaInput(part(), now()), displayOptions?.()?.metadata)) +} async function writeClipboard(text: string): Promise { const body = typeof document === "undefined" ? undefined : document.body @@ -161,6 +205,7 @@ export interface MessageProps { actions?: UserActions showAssistantCopyPartID?: string | null showReasoningSummaries?: boolean + displayOptions?: MessageDisplayOptions } export type SessionAction = (input: { sessionID: string; messageID: string }) => Promise | void @@ -182,6 +227,7 @@ export interface MessagePartProps { onContentRendered?: () => void showAssistantCopyPartID?: string | null turnDurationMs?: number + displayOptions?: MessageDisplayOptions } export type PartComponent = Component @@ -644,6 +690,7 @@ export function AssistantParts(props: { showReasoningSummaries?: boolean shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean + displayOptions?: MessageDisplayOptions }) { const data = useData() const emptyParts: PartType[] = [] @@ -698,7 +745,7 @@ export function AssistantParts(props: { return ( 0}> - + ) })()} @@ -724,6 +771,7 @@ export function AssistantParts(props: { message={message()!} showAssistantCopyPartID={props.showAssistantCopyPartID} turnDurationMs={props.turnDurationMs} + displayOptions={props.displayOptions} defaultOpen={partDefaultOpen(item()!, props.shellToolDefaultOpen, props.editToolDefaultOpen)} /> @@ -851,7 +899,12 @@ export function Message(props: MessageProps) { {(userMessage) => ( - + )} @@ -861,6 +914,7 @@ export function Message(props: MessageProps) { parts={props.parts} showAssistantCopyPartID={props.showAssistantCopyPartID} showReasoningSummaries={props.showReasoningSummaries} + displayOptions={props.displayOptions} /> )} @@ -873,6 +927,7 @@ export function AssistantMessageDisplay(props: { parts: PartType[] showAssistantCopyPartID?: string | null showReasoningSummaries?: boolean + displayOptions?: MessageDisplayOptions }) { const emptyTools: ToolPart[] = [] const part = createMemo(() => index(props.parts)) @@ -913,7 +968,7 @@ export function AssistantMessageDisplay(props: { return ( 0}> - + ) })()} @@ -932,6 +987,7 @@ export function AssistantMessageDisplay(props: { part={item()!} message={props.message} showAssistantCopyPartID={props.showAssistantCopyPartID} + displayOptions={props.displayOptions} /> ) @@ -944,7 +1000,12 @@ export function AssistantMessageDisplay(props: { ) } -export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSizeChange?: () => void }) { +export function ContextToolGroup(props: { + parts: ToolPart[] + busy?: boolean + onSizeChange?: () => void + displayOptions?: MessageDisplayOptions +}) { const i18n = useI18n() const [open, setOpen] = createSignal(false) const pending = createMemo( @@ -1016,6 +1077,7 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onS {(partAccessor) => { const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n)) + const meta = createToolMeta(partAccessor, i18n, () => props.displayOptions) const running = createMemo( () => partAccessor().state.status === "pending" || partAccessor().state.status === "running", ) @@ -1040,6 +1102,14 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onS + + + {meta()} + + @@ -1052,7 +1122,12 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onS ) } -export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[]; actions?: UserActions }) { +export function UserMessageDisplay(props: { + message: UserMessage + parts: PartType[] + actions?: UserActions + displayOptions?: MessageDisplayOptions +}) { const data = useData() const dialog = useDialog() const i18n = useI18n() @@ -1084,12 +1159,11 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp const match = data.store.provider?.all?.get(providerID) return match?.models?.[modelID]?.name ?? modelID }) - const timefmt = createMemo(() => new Intl.DateTimeFormat(i18n.locale(), { timeStyle: "short" })) - const stamp = createMemo(() => { + if (!props.displayOptions?.metadata?.messageTimestamp) return "" const created = props.message.time?.created if (typeof created !== "number") return "" - return timefmt().format(created) + return formatMessageStamp(i18n.locale(), created) }) const metaHead = createMemo(() => { @@ -1286,6 +1360,7 @@ export function Part(props: MessagePartProps) { onContentRendered={props.onContentRendered} showAssistantCopyPartID={props.showAssistantCopyPartID} turnDurationMs={props.turnDurationMs} + displayOptions={props.displayOptions} /> ) @@ -1307,6 +1382,7 @@ export interface ToolProps { onContentRendered?: () => void forceOpen?: boolean locked?: boolean + meta?: string } export type ToolComponent = Component @@ -1383,8 +1459,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const emptyMetadata: Record = {} const input = () => part().state?.input ?? emptyInput - // @ts-expect-error - const partMetadata = () => part().state?.metadata ?? emptyMetadata + const partMetadata = () => toolPartMetadata(part(), emptyMetadata) const taskId = createMemo(() => { if (part().tool !== "task") return const value = partMetadata().sessionId @@ -1404,12 +1479,13 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool) const controlledOpen = () => (props.onToolOpenChange ? (props.toolOpen ?? props.defaultOpen) : undefined) const handleToolOpenChange = (open: boolean) => props.onToolOpenChange?.(open) + const meta = createToolMeta(part, i18n, () => props.displayOptions) return (
- + {(error) => { const cleaned = error().replace("Error: ", "") if (part().tool === "question" && cleaned.includes("dismissed this question")) { @@ -1431,6 +1507,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined} subtitle={taskSubtitle()} href={taskHref()} + meta={meta()} /> ) }} @@ -1442,8 +1519,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { tool={part().tool} sessionID={part().sessionID} metadata={partMetadata()} - // @ts-expect-error - output={part().state.output} + output={toolPartOutput(part())} status={part().state.status} hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} @@ -1452,6 +1528,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { deferContent={props.deferToolContent} virtualizeDiff={props.virtualizeDiff} onContentRendered={props.onContentRendered} + meta={meta()} /> @@ -1519,9 +1596,10 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { const meta = createMemo(() => { if (props.message.role !== "assistant") return "" - const agent = (props.message as AssistantMessage).agent + const message = props.message as AssistantMessage const items = [ - agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", + props.displayOptions?.metadata?.messageTimestamp ? formatMessageStamp(i18n.locale(), message.time.created) : "", + message.agent ? message.agent[0]?.toUpperCase() + message.agent.slice(1) : "", model(), duration(), interrupted() ? i18n.t("ui.message.interrupted") : "", @@ -1576,7 +1654,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { size="normal" variant="ghost" onMouseDown={(e) => e.preventDefault()} - onClick={handleCopy} + onClick={(event) => handleCopyResponseClick(event, () => void handleCopy())} aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} /> @@ -1874,6 +1952,7 @@ ToolRegistry.register({ triggerHref={href()} clickable={clickable()} onTriggerClick={navigate} + meta={props.meta} /> ) }, @@ -2431,6 +2510,6 @@ ToolRegistry.register({
) - return + return }, }) diff --git a/packages/session-ui/src/components/tool-error-card.tsx b/packages/session-ui/src/components/tool-error-card.tsx index 35720a275313..e351a185db9d 100644 --- a/packages/session-ui/src/components/tool-error-card.tsx +++ b/packages/session-ui/src/components/tool-error-card.tsx @@ -16,6 +16,7 @@ export interface ToolErrorCardProps extends Omit, "c onOpenChange?: (open: boolean) => void subtitle?: string href?: string + meta?: string } export function ToolErrorCard(props: ToolErrorCardProps) { @@ -35,6 +36,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) { "onOpenChange", "subtitle", "href", + "meta", ]) const setOpen = (value: boolean) => { if (props.open === undefined) setState("open", value) @@ -119,6 +121,11 @@ export function ToolErrorCard(props: ToolErrorCardProps) { + + + {split.meta} + + diff --git a/packages/session-ui/src/components/tool-meta.ts b/packages/session-ui/src/components/tool-meta.ts new file mode 100644 index 000000000000..eaed19b3a938 --- /dev/null +++ b/packages/session-ui/src/components/tool-meta.ts @@ -0,0 +1,41 @@ +import type { UiI18n } from "@opencode-ai/ui/context/i18n" +import type { MessageMetadataDisplayOptions } from "./message-options" + +export type ToolMetaInput = { + status: "pending" | "running" | "completed" | "error" | "interrupted" + start?: number + end?: number + now?: number +} + +export function toolMeta(i18n: UiI18n, input: ToolMetaInput, options?: MessageMetadataDisplayOptions) { + const stamp = options?.toolTimestamp && typeof input.start === "number" ? formatTime(i18n.locale(), input.start) : "" + if (input.status === "interrupted") { + return [stamp, options?.toolStatus ? i18n.t("ui.message.interrupted") : ""].filter(Boolean).join(" · ") + } + if (input.status === "error") { + return [stamp, options?.toolStatus ? i18n.t("ui.toolErrorCard.failed") : ""].filter(Boolean).join(" · ") + } + + const end = input.status === "completed" ? input.end : input.now + const duration = + options?.toolDuration && typeof input.start === "number" && typeof end === "number" + ? formatDuration(i18n, end - input.start) + : "" + return [stamp, duration].filter(Boolean).join(" · ") +} + +function formatTime(locale: string, value: number) { + return new Intl.DateTimeFormat(locale, { timeStyle: "short" }).format(value) +} + +function formatDuration(i18n: UiI18n, value: number) { + if (value < 0) return "" + const total = Math.round(value / 1000) + if (total < 60) return i18n.t("ui.message.duration.seconds", { count: String(total) }) + + return i18n.t("ui.message.duration.minutesSeconds", { + minutes: String(Math.floor(total / 60)), + seconds: String(total % 60), + }) +} diff --git a/script/.env.example b/script/.env.example new file mode 100644 index 000000000000..e256a6f122ee --- /dev/null +++ b/script/.env.example @@ -0,0 +1,20 @@ +OPENCODE_SERVER_PASSWORD= +PATH=/root/.nvm/versions/node/v24.15.0/bin:/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin +GIT_AUTHOR_NAME=Sisyphus +GIT_AUTHOR_EMAIL=clio-agent@sisyphuslabs.ai +GIT_COMMITTER_NAME=Sisyphus +GIT_COMMITTER_EMAIL=clio-agent@sisyphuslabs.ai +REPO_DIR=/var/opt/opencode-upstream +UPSTREAM_URL=https://github.com/sst/opencode.git +FORK_URL=https://github.com/CryptoAlchemik/opencode.git +UPSTREAM_REMOTE=origin +UPSTREAM_BRANCH=dev +FORK_REMOTE=fork +BASE_PATCH_BRANCH=tool-timestamp-metadata +FEATURE_BRANCH=jump-user-message +STACK_SCRIPT=/var/opt/opencode-telegram-group-topics-bot/opencode-stack.sh +TARGET_BIN=/root/.opencode/bin/opencode +HEALTH_URL=http://127.0.0.1:4416/global/health +SESSION_DIR=/root/.local/share/opencode +BACKUP_ROOT=/root/.local/share/opencode/backups +ALLOW_DIRTY=0 diff --git a/script/update-local-opencode.sh b/script/update-local-opencode.sh new file mode 100755 index 000000000000..3ac13ba6f7f6 --- /dev/null +++ b/script/update-local-opencode.sh @@ -0,0 +1,412 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: script/update-local-opencode.sh [--execute] + +Updates the local production OpenCode binary from this fork stack: + 1. clone the source checkout if REPO_DIR does not exist yet + 2. fetch upstream and fork remotes + 3. rebase the base patch branch onto upstream dev + 4. rebase the feature branch onto the base patch branch + 5. install dependencies and build the current-platform binary + 6. back up OpenCode session data before stopping the stack + 7. stop the stack, make a final session backup, replace the binary, start the stack + +Default mode is dry-run. Pass --execute to stop/start prod and replace the binary. +If REPO_DIR exists but is not a git checkout, --execute moves it aside to +REPO_DIR.bootstrap-backup. before cloning a fresh checkout. + +Environment overrides: + ENV_FILE default: .env next to this script; sourced when present + REPO_DIR default: /var/opt/opencode-upstream + UPSTREAM_URL default: https://github.com/sst/opencode.git + FORK_URL default: https://github.com/CryptoAlchemik/opencode.git + UPSTREAM_REMOTE default: origin + UPSTREAM_BRANCH default: dev + FORK_REMOTE default: fork + BASE_PATCH_BRANCH default: tool-timestamp-metadata + FEATURE_BRANCH default: jump-user-message + STACK_SCRIPT default: /var/opt/opencode-telegram-group-topics-bot/opencode-stack.sh + TARGET_BIN default: /root/.opencode/bin/opencode + HEALTH_URL default: http://127.0.0.1:4416/global/health + OPENCODE_SERVER_PASSWORD used for authenticated health checks when set; + prompts in interactive sessions when unset + SESSION_DIR default: /root/.local/share/opencode + BACKUP_ROOT default: /root/.local/share/opencode/backups + ALLOW_DIRTY default: 0; set 1 to allow tracked worktree changes +EOF +} + +EXECUTE=0 +case "${1:-}" in + "") ;; + --execute) EXECUTE=1 ;; + -h|--help|help) usage; exit 0 ;; + *) usage; exit 2 ;; +esac + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/.env}" + +load_env_file() { + if [[ ! -f "$ENV_FILE" ]]; then + return 0 + fi + + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a +} + +load_env_file + +REPO_DIR="${REPO_DIR:-/var/opt/opencode-upstream}" +UPSTREAM_URL="${UPSTREAM_URL:-https://github.com/sst/opencode.git}" +FORK_URL="${FORK_URL:-https://github.com/CryptoAlchemik/opencode.git}" +UPSTREAM_REMOTE="${UPSTREAM_REMOTE:-origin}" +UPSTREAM_BRANCH="${UPSTREAM_BRANCH:-dev}" +FORK_REMOTE="${FORK_REMOTE:-fork}" +BASE_PATCH_BRANCH="${BASE_PATCH_BRANCH:-tool-timestamp-metadata}" +FEATURE_BRANCH="${FEATURE_BRANCH:-jump-user-message}" +STACK_SCRIPT="${STACK_SCRIPT:-/var/opt/opencode-telegram-group-topics-bot/opencode-stack.sh}" +TARGET_BIN="${TARGET_BIN:-/root/.opencode/bin/opencode}" +HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:4416/global/health}" +SESSION_DIR="${SESSION_DIR:-/root/.local/share/opencode}" +BACKUP_ROOT="${BACKUP_ROOT:-/root/.local/share/opencode/backups}" +ALLOW_DIRTY="${ALLOW_DIRTY:-0}" + +timestamp() { + date -u +%Y%m%dT%H%M%SZ +} + +log() { + printf '[%s] %s\n' "$(timestamp)" "$*" +} + +if [[ -f "$ENV_FILE" ]]; then + log "loaded env file: $ENV_FILE" +fi + +run() { + log "+ $*" + if (( EXECUTE == 1 )); then + "$@" + fi +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { + log "missing command: $1" + exit 1 + } +} + +require_path() { + [[ -e "$1" ]] || { + log "missing path: $1" + exit 1 + } +} + +git_run() { + run env GIT_MASTER=1 git "$@" +} + +git_capture() { + env GIT_MASTER=1 git "$@" +} + +ensure_repo() { + if [[ -d "$REPO_DIR/.git" ]]; then + log "using existing checkout: $REPO_DIR" + return 0 + fi + + if [[ -e "$REPO_DIR" ]]; then + local backup_dir="$REPO_DIR.bootstrap-backup.$(timestamp)" + if (( EXECUTE == 1 )); then + mv "$REPO_DIR" "$backup_dir" + log "moved non-git REPO_DIR aside: $backup_dir" + else + log "+ mv $REPO_DIR $backup_dir" + log "dry-run bootstrap stops here because REPO_DIR exists but is not a git checkout" + exit 0 + fi + fi + + local parent + parent="$(dirname "$REPO_DIR")" + if (( EXECUTE == 1 )); then + mkdir -p "$parent" + env GIT_MASTER=1 git clone --origin "$UPSTREAM_REMOTE" --branch "$UPSTREAM_BRANCH" "$UPSTREAM_URL" "$REPO_DIR" + else + log "+ mkdir -p $parent" + log "+ env GIT_MASTER=1 git clone --origin $UPSTREAM_REMOTE --branch $UPSTREAM_BRANCH $UPSTREAM_URL $REPO_DIR" + log "dry-run bootstrap stops here because REPO_DIR does not exist yet" + exit 0 + fi +} + +ensure_remote() { + local name="$1" + local url="$2" + + if git_capture remote get-url "$name" >/dev/null 2>&1; then + git_run remote set-url "$name" "$url" + return 0 + fi + + git_run remote add "$name" "$url" +} + +checkout_branch_from_remote() { + local branch="$1" + local remote="$2" + + if git_capture show-ref --verify --quiet "refs/heads/$branch"; then + git_run checkout "$branch" + return 0 + fi + + git_run checkout -b "$branch" "$remote/$branch" +} + +require_clean_tracked_tree() { + if [[ "$ALLOW_DIRTY" == "1" ]]; then + log "ALLOW_DIRTY=1 set; tracked worktree cleanliness check skipped" + return 0 + fi + + local status + status="$(git_capture status --porcelain --untracked-files=no)" + [[ -z "$status" ]] || { + log "tracked worktree has changes; commit/stash them or set ALLOW_DIRTY=1" + printf '%s\n' "$status" + exit 1 + } +} + +load_health_password() { + if [[ -n "${OPENCODE_SERVER_PASSWORD:-}" ]]; then + log "using OPENCODE_SERVER_PASSWORD from environment" + return 0 + fi + + if [[ -t 0 ]]; then + local password="" + printf 'OPENCODE_SERVER_PASSWORD is unset; enter password for authenticated health checks, or leave blank to skip auth: ' + read -rs password || password="" + printf '\n' + + if [[ -n "$password" ]]; then + export OPENCODE_SERVER_PASSWORD="$password" + log "using OPENCODE_SERVER_PASSWORD from interactive prompt" + return 0 + fi + + log "no health-check password entered; health check will run without auth" + return 0 + fi + + log "OPENCODE_SERVER_PASSWORD is not set and session is non-interactive; health check will run without auth" +} + +backup_sessions() { + local label="$1" + local backup_file="$BACKUP_ROOT/opencode-sessions-$label-$(timestamp).tar.gz" + + require_path "$SESSION_DIR" + run mkdir -p "$BACKUP_ROOT" + log "session backup target: $backup_file" + if (( EXECUTE == 1 )); then + tar --warning=no-file-changed --ignore-failed-read -C "$(dirname "$SESSION_DIR")" -czf "$backup_file" "$(basename "$SESSION_DIR")" + fi +} + +wait_for_port() { + local port="$1" + for _ in {1..180}; do + if ss -ltn | grep -qE "(^|:)${port}[[:space:]]"; then + log "port $port is listening" + return 0 + fi + sleep 1 + done + log "port $port did not open in time" + return 1 +} + +verify_binary() { + local binary="$1" + [[ -f "$binary" && -x "$binary" && ! -L "$binary" ]] || { + log "binary is missing, not executable, or is a symlink: $binary" + return 1 + } + + "$binary" --version >/dev/null +} + +verify_stack_health() { + wait_for_port 4416 || return 1 + verify_binary "$TARGET_BIN" || return 1 + if [[ -n "${OPENCODE_SERVER_PASSWORD:-}" ]]; then + curl -fsS --max-time 5 -u "opencode:${OPENCODE_SERVER_PASSWORD}" "$HEALTH_URL" >/dev/null + return $? + fi + curl -fsS --max-time 5 "$HEALTH_URL" >/dev/null +} + +backup_binary() { + local source="$1" destination="$2" + + [[ -f "$source" && -x "$source" && ! -L "$source" ]] || { + log "cannot create rollback backup; target binary is missing, not executable, or is a symlink: $source" + return 1 + } + [[ ! -e "$destination" ]] || { + log "refusing to overwrite existing binary backup: $destination" + return 1 + } + + cp -a "$source" "$destination" + [[ -f "$destination" && -x "$destination" ]] || { + log "binary backup validation failed: $destination" + return 1 + } + log "current binary backup: $destination" +} + +install_binary() { + local source="$1" destination="$2" + + install -m 755 "$source" "$destination.new" + mv -f "$destination.new" "$destination" +} + +rollback_binary() { + local binary_backup="$1" + + log "rolling back $TARGET_BIN from $binary_backup" + [[ -f "$binary_backup" && -x "$binary_backup" ]] || { + log "rollback backup is unavailable or invalid: $binary_backup" + return 1 + } + + "$STACK_SCRIPT" stop || true + install_binary "$binary_backup" "$TARGET_BIN" + "$STACK_SCRIPT" start + verify_stack_health + log "rollback completed; backup retained: $binary_backup" +} + +confirm_backup_removal() { + local binary_backup="$1" answer="" + + if [[ ! -t 0 ]]; then + log "non-interactive session; retaining rollback backup: $binary_backup" + return 0 + fi + + printf 'Confirm production was manually checked and remove rollback backup %s? [y/N] ' "$binary_backup" + read -r answer || answer="" + case "${answer,,}" in + y|yes) + rm -f -- "$binary_backup" + log "removed confirmed binary backup: $binary_backup" + ;; + *) + log "retaining rollback backup: $binary_backup" + ;; + esac +} + +main() { + require_cmd bash + require_cmd bun + require_cmd curl + require_cmd git + require_cmd ss + require_cmd tar + require_path "$STACK_SCRIPT" + + ensure_repo + cd "$REPO_DIR" + + if (( EXECUTE == 0 )); then + log "dry-run mode; pass --execute to perform changes" + fi + + load_health_password + + require_clean_tracked_tree + + ensure_remote "$UPSTREAM_REMOTE" "$UPSTREAM_URL" + ensure_remote "$FORK_REMOTE" "$FORK_URL" + git_run fetch "$UPSTREAM_REMOTE" "$UPSTREAM_BRANCH" + git_run fetch "$FORK_REMOTE" "$BASE_PATCH_BRANCH" "$FEATURE_BRANCH" + checkout_branch_from_remote "$BASE_PATCH_BRANCH" "$FORK_REMOTE" + git_run rebase "$UPSTREAM_REMOTE/$UPSTREAM_BRANCH" + checkout_branch_from_remote "$FEATURE_BRANCH" "$FORK_REMOTE" + git_run rebase "$BASE_PATCH_BRANCH" + + run bun install + run bun run --cwd packages/opencode build --single + + local built_bin="$REPO_DIR/packages/opencode/dist/opencode-linux-x64/bin/opencode" + if (( EXECUTE == 1 )); then + require_path "$built_bin" + verify_binary "$built_bin" + else + log "would verify built binary: $built_bin --version" + fi + + backup_sessions "pre-stop" + + local binary_backup="$TARGET_BIN.$(timestamp).bak" + require_path "$(dirname "$TARGET_BIN")" + if (( EXECUTE == 1 )); then + backup_binary "$TARGET_BIN" "$binary_backup" + + "$STACK_SCRIPT" stop + if ! backup_sessions "post-stop"; then + log "post-stop session backup failed; restarting old binary" + "$STACK_SCRIPT" start || true + exit 1 + fi + + if ! install_binary "$built_bin" "$TARGET_BIN"; then + log "new binary install failed" + rollback_binary "$binary_backup" || log "rollback failed; backup retained: $binary_backup" + exit 1 + fi + + if ! "$STACK_SCRIPT" start; then + log "new binary failed during stack start" + rollback_binary "$binary_backup" || log "rollback failed; backup retained: $binary_backup" + exit 1 + fi + + if ! verify_stack_health; then + log "new binary failed health check" + rollback_binary "$binary_backup" || log "rollback failed; backup retained: $binary_backup" + exit 1 + fi + + log "new binary passed startup and health checks" + confirm_backup_removal "$binary_backup" + else + log "would back up current binary to: $binary_backup" + log "would stop stack with: $STACK_SCRIPT stop" + log "would create post-stop session backup" + log "would install built binary to: $TARGET_BIN" + log "would start stack with: $STACK_SCRIPT start" + log "would roll back from $binary_backup if stack start or health check fails" + log "would retain rollback backup unless an interactive user confirms removal" + log "would wait for port 4416, verify $TARGET_BIN --version, and check $HEALTH_URL" + fi +} + +main "$@"