diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..8c2005e0 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,2 @@ +FROM oven/bun:1.3 +WORKDIR /workspace \ No newline at end of file diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 00000000..f6c72083 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,14 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "version": "2.5.9", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "1.3.8", + "resolved": "ghcr.io/devcontainers/features/git@sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2", + "integrity": "sha256:fd75977de13a9979000e0e78baf949adb0ca71d2398995fa22e0a36d7e7e7fe2" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..0a98b0ae --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,13 @@ +{ + "name": "opencode-dev", + "build": { "dockerfile": "Dockerfile" }, + "features": { + "ghcr.io/devcontainers/features/common-utils:2": {}, + "ghcr.io/devcontainers/features/git:1": {} + }, + "customizations": { + "vscode": { + "extensions": ["oven.bun-vscode"] + } + } +} \ No newline at end of file diff --git a/packages/web/src/components/Share.test.ts b/packages/web/src/components/Share.test.ts new file mode 100644 index 00000000..47c1415f --- /dev/null +++ b/packages/web/src/components/Share.test.ts @@ -0,0 +1,371 @@ +import { describe, test, expect } from "bun:test" +import { isVisiblePart, summarizeSession, shouldShowScrollButton, disposeIfSet, type MessageWithParts } from "./Share" +import type { MessageV2 } from "opencode/session/message-v2" +import type { Session } from "opencode/session/index" + +// --- fixtures ----------------------------------------------------------- + +let partSeq = 0 +function nextPartID() { + partSeq += 1 + return `prt_${partSeq}` +} + +function textPart(overrides: Partial & { text?: string } = {}): MessageV2.Part { + return { + id: nextPartID(), + sessionID: "ses_1", + messageID: "msg_1", + type: "text", + text: "hello", + ...overrides, + } as MessageV2.Part +} + +function stepStartPart(overrides: Partial = {}): MessageV2.Part { + return { + id: nextPartID(), + sessionID: "ses_1", + messageID: "msg_1", + type: "step-start", + ...overrides, + } as MessageV2.Part +} + +function stepFinishPart(overrides: Partial = {}): MessageV2.Part { + return { + id: nextPartID(), + sessionID: "ses_1", + messageID: "msg_1", + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + ...overrides, + } as MessageV2.Part +} + +function snapshotPart(overrides: Partial = {}): MessageV2.Part { + return { + id: nextPartID(), + sessionID: "ses_1", + messageID: "msg_1", + type: "snapshot", + snapshot: "abc123", + ...overrides, + } as MessageV2.Part +} + +function patchPart(overrides: Partial = {}): MessageV2.Part { + return { + id: nextPartID(), + sessionID: "ses_1", + messageID: "msg_1", + type: "patch", + hash: "deadbeef", + files: ["a.ts"], + ...overrides, + } as MessageV2.Part +} + +function toolPart(status: "pending" | "running" | "completed" | "error", overrides: Partial = {}): MessageV2.Part { + const state = { + pending: { status: "pending", input: {}, raw: "" }, + running: { status: "running", input: {}, time: { start: 0 } }, + completed: { status: "completed", input: {}, output: "done", title: "Bash", metadata: {}, time: { start: 0, end: 1 } }, + error: { status: "error", input: {}, error: "boom", time: { start: 0, end: 1 } }, + }[status] + + return { + id: nextPartID(), + sessionID: "ses_1", + messageID: "msg_1", + type: "tool", + callID: "call_1", + tool: "bash", + state, + ...overrides, + } as MessageV2.Part +} + +function reasoningPart(overrides: Partial = {}): MessageV2.Part { + return { + id: nextPartID(), + sessionID: "ses_1", + messageID: "msg_1", + type: "reasoning", + text: "thinking...", + time: { start: 0 }, + ...overrides, + } as MessageV2.Part +} + +let msgSeq = 0 +function nextMessageID() { + msgSeq += 1 + return `msg_${msgSeq}` +} + +function assistantMessage(overrides: Record = {}, parts: MessageV2.Part[] = []): MessageWithParts { + return { + id: nextMessageID(), + sessionID: "ses_1", + role: "assistant", + time: { created: 0 }, + parentID: "msg_0", + modelID: "claude-sonnet-5", + providerID: "anthropic", + mode: "build", + agent: "build", + path: { cwd: "/repo", root: "/repo" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + ...overrides, + parts, + } as unknown as MessageWithParts +} + +function userMessage(overrides: Record = {}, parts: MessageV2.Part[] = []): MessageWithParts { + return { + id: nextMessageID(), + sessionID: "ses_1", + role: "user", + time: { created: 0 }, + agent: "build", + model: { providerID: "anthropic", modelID: "claude-sonnet-5" }, + ...overrides, + parts, + } as unknown as MessageWithParts +} + +function sessionInfo(overrides: Record = {}): Session.Info { + return { + time: { created: 1000 }, + ...overrides, + } as unknown as Session.Info +} + +// --- isVisiblePart -------------------------------------------------------- + +describe("isVisiblePart", () => { + test("keeps a step-start part at index 0", () => { + expect(isVisiblePart(stepStartPart(), 0)).toBe(true) + }) + + test("hides a step-start part at any later index", () => { + expect(isVisiblePart(stepStartPart(), 1)).toBe(false) + expect(isVisiblePart(stepStartPart(), 5)).toBe(false) + }) + + test("always hides snapshot parts", () => { + expect(isVisiblePart(snapshotPart(), 0)).toBe(false) + }) + + test("always hides patch parts", () => { + expect(isVisiblePart(patchPart(), 0)).toBe(false) + }) + + test("always hides step-finish parts", () => { + expect(isVisiblePart(stepFinishPart(), 0)).toBe(false) + }) + + test("hides synthetic text parts", () => { + expect(isVisiblePart(textPart({ synthetic: true, text: "hidden" }), 2)).toBe(false) + }) + + test("hides empty text parts", () => { + expect(isVisiblePart(textPart({ text: "" }), 2)).toBe(false) + }) + + test("shows non-empty, non-synthetic text parts", () => { + expect(isVisiblePart(textPart({ text: "hi", synthetic: false }), 2)).toBe(true) + }) + + test("shows text parts when synthetic is left unset", () => { + expect(isVisiblePart(textPart({ text: "hi" }), 2)).toBe(true) + }) + + test("hides pending and running tool parts", () => { + expect(isVisiblePart(toolPart("pending"), 2)).toBe(false) + expect(isVisiblePart(toolPart("running"), 2)).toBe(false) + }) + + test("shows completed and error tool parts", () => { + expect(isVisiblePart(toolPart("completed"), 2)).toBe(true) + expect(isVisiblePart(toolPart("error"), 2)).toBe(true) + }) + + test("shows part types with no explicit rule, e.g. reasoning", () => { + expect(isVisiblePart(reasoningPart(), 2)).toBe(true) + }) + + test("filters a realistic mixed part list the way Share renders it", () => { + const parts = [ + stepStartPart(), // index 0 -> visible + textPart({ text: "Attached media from tool result:", synthetic: true }), // hidden + toolPart("pending"), // hidden + toolPart("completed"), // visible + textPart({ text: "" }), // hidden + textPart({ text: "final answer" }), // visible + stepFinishPart(), // hidden + snapshotPart(), // hidden + patchPart(), // hidden + stepStartPart(), // index 8 -> hidden (not first) + ] + + const visible = parts.filter(isVisiblePart) + expect(visible).toHaveLength(3) + expect(visible.map((p) => p.type)).toEqual(["step-start", "tool", "text"]) + }) +}) + +// --- summarizeSession ------------------------------------------------------- + +describe("summarizeSession", () => { + test("returns a zeroed result when info is undefined, regardless of messages", () => { + const result = summarizeSession(undefined, [assistantMessage({ cost: 5 })]) + expect(result).toEqual({ + rootDir: undefined, + created: undefined, + completed: undefined, + messages: [], + models: {}, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0 }, + }) + }) + + test("picks up created time from info even with no messages", () => { + const result = summarizeSession(sessionInfo({ time: { created: 4242 } }), []) + expect(result.created).toBe(4242) + expect(result.messages).toEqual([]) + expect(result.cost).toBe(0) + }) + + test("aggregates cost, tokens, and model for a single assistant message", () => { + const msg = assistantMessage({ + providerID: "anthropic", + modelID: "claude-sonnet-5", + cost: 1.5, + tokens: { input: 100, output: 200, reasoning: 10, cache: { read: 0, write: 0 } }, + path: { cwd: "/repo", root: "/repo/root" }, + time: { created: 0, completed: 999 }, + }) + + const result = summarizeSession(sessionInfo(), [msg]) + + expect(result.cost).toBe(1.5) + expect(result.tokens).toEqual({ input: 100, output: 200, reasoning: 10 }) + expect(result.models).toEqual({ "anthropic claude-sonnet-5": ["anthropic", "claude-sonnet-5"] }) + expect(result.rootDir).toBe("/repo/root") + expect(result.completed).toBe(999) + expect(result.messages).toEqual([msg]) + }) + + test("sums cost and tokens across multiple assistant messages", () => { + const first = assistantMessage({ + cost: 1, + tokens: { input: 10, output: 20, reasoning: 1, cache: { read: 0, write: 0 } }, + }) + const second = assistantMessage({ + cost: 2.5, + tokens: { input: 5, output: 15, reasoning: 2, cache: { read: 0, write: 0 } }, + }) + + const result = summarizeSession(sessionInfo(), [first, second]) + + expect(result.cost).toBe(3.5) + expect(result.tokens).toEqual({ input: 15, output: 35, reasoning: 3 }) + }) + + test("includes user messages in the message list without affecting cost/tokens/models", () => { + const user = userMessage() + const assistant = assistantMessage({ cost: 2, tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } } }) + + const result = summarizeSession(sessionInfo(), [user, assistant]) + + expect(result.messages).toEqual([user, assistant]) + expect(result.cost).toBe(2) + expect(Object.keys(result.models)).toEqual(["anthropic claude-sonnet-5"]) + }) + + test("keeps the last completed time and rootDir when messages disagree", () => { + const first = assistantMessage({ path: { cwd: "/repo", root: "/repo/a" }, time: { created: 0, completed: 100 } }) + const second = assistantMessage({ path: { cwd: "/repo", root: "/repo/b" }, time: { created: 0, completed: 200 } }) + + const result = summarizeSession(sessionInfo(), [first, second]) + + expect(result.rootDir).toBe("/repo/b") + expect(result.completed).toBe(200) + }) + + test("does not clear rootDir/completed when a later message omits them", () => { + const first = assistantMessage({ path: { cwd: "/repo", root: "/repo/a" }, time: { created: 0, completed: 100 } }) + const second = assistantMessage({ path: { cwd: "/repo", root: "" }, time: { created: 0 } }) + + const result = summarizeSession(sessionInfo(), [first, second]) + + expect(result.rootDir).toBe("/repo/a") + expect(result.completed).toBe(100) + }) + + test("merges distinct provider/model pairs into separate model entries", () => { + const first = assistantMessage({ providerID: "anthropic", modelID: "claude-sonnet-5" }) + const second = assistantMessage({ providerID: "openai", modelID: "gpt-5" }) + + const result = summarizeSession(sessionInfo(), [first, second]) + + expect(result.models).toEqual({ + "anthropic claude-sonnet-5": ["anthropic", "claude-sonnet-5"], + "openai gpt-5": ["openai", "gpt-5"], + }) + }) +}) + +// --- shouldShowScrollButton ------------------------------------------------- + +describe("shouldShowScrollButton", () => { + test("shows when scrolling down past the threshold and not near bottom", () => { + expect(shouldShowScrollButton({ currentScrollY: 300, lastScrollY: 100, isNearBottom: false })).toBe(true) + }) + + test("stays hidden when scrolling up, even past the threshold", () => { + expect(shouldShowScrollButton({ currentScrollY: 300, lastScrollY: 400, isNearBottom: false })).toBe(false) + }) + + test("stays hidden when scrolling down but under the 200px threshold", () => { + expect(shouldShowScrollButton({ currentScrollY: 150, lastScrollY: 50, isNearBottom: false })).toBe(false) + }) + + test("stays hidden near the bottom even while scrolling down past the threshold", () => { + expect(shouldShowScrollButton({ currentScrollY: 300, lastScrollY: 100, isNearBottom: true })).toBe(false) + }) + + test("treats an unchanged scroll position as not scrolling down", () => { + expect(shouldShowScrollButton({ currentScrollY: 300, lastScrollY: 300, isNearBottom: false })).toBe(false) + }) +}) + +// --- disposeIfSet ------------------------------------------------------------ + +describe("disposeIfSet", () => { + test("calls dispose with the value when it is set", () => { + const seen: number[] = [] + disposeIfSet(42, (value) => seen.push(value)) + expect(seen).toEqual([42]) + }) + + test("does not call dispose when the value is undefined", () => { + let called = false + disposeIfSet(undefined, () => (called = true)) + expect(called).toBe(false) + }) + + test("does not call dispose for falsy-but-meaningful values like 0", () => { + // Matches the source's `if (scrollTimeout)` checks, which also treat a + // timer id of 0 as "not set" - documenting that rather than changing it. + let called = false + disposeIfSet(0, () => (called = true)) + expect(called).toBe(false) + }) +}) diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index 04514b21..073e7947 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -10,7 +10,7 @@ import type { Message } from "opencode/session/message" import type { Session } from "opencode/session/index" import { Part, ProviderIcon } from "./share/part" -type MessageWithParts = MessageV2.Info & { parts: MessageV2.Part[] } +export type MessageWithParts = MessageV2.Info & { parts: MessageV2.Part[] } type Status = "disconnected" | "connecting" | "connected" | "error" | "reconnecting" @@ -38,24 +38,178 @@ function getStatusText(status: [Status, string?], messages: Record 0) return false + if (part.type === "snapshot") return false + if (part.type === "patch") return false + if (part.type === "step-finish") return false + if (part.type === "text" && part.synthetic === true) return false + if (part.type === "text" && !part.text) return false + if (part.type === "tool" && (part.state.status === "pending" || part.state.status === "running")) return false + return true +} + +export function summarizeSession(info: Session.Info | undefined, msgs: MessageWithParts[]) { + const result = { + rootDir: undefined as string | undefined, + created: undefined as number | undefined, + completed: undefined as number | undefined, + messages: [] as MessageWithParts[], + models: {} as Record, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + }, + } + + if (!info) return result + + result.created = info.time.created + + for (const msg of msgs) { + result.messages.push(msg) + + if (msg.role === "assistant") { + result.cost += msg.cost + result.tokens.input += msg.tokens.input + result.tokens.output += msg.tokens.output + result.tokens.reasoning += msg.tokens.reasoning + + result.models[`${msg.providerID} ${msg.modelID}`] = [msg.providerID, msg.modelID] + + if (msg.path.root) { + result.rootDir = msg.path.root + } + + if (msg.time.completed) { + result.completed = msg.time.completed + } + } + } + return result +} + +// Only show when scrolling down, scrolled enough, and not near bottom. +export function shouldShowScrollButton(params: { currentScrollY: number; lastScrollY: number; isNearBottom: boolean }) { + const isScrollingDown = params.currentScrollY > params.lastScrollY + const scrolled = params.currentScrollY > 200 // Show after scrolling 200px + return isScrollingDown && scrolled && !params.isNearBottom +} + +// Runs `dispose(value)` only if `value` is set - shared by every "clear this +// timeout/observer/element if it exists" cleanup spot in useScrollButton. +export function disposeIfSet(value: T | undefined, dispose: (value: T) => void) { + if (value) dispose(value) +} + +// Encapsulates the floating "scroll to bottom" button: visibility on scroll-down, +// hover-to-persist, auto-hide timers, and the near-bottom IntersectionObserver. +function useScrollButton() { + let lastScrollY = 0 + let scrollTimeout: number | undefined + let scrollSentinel: HTMLElement | undefined + let scrollObserver: IntersectionObserver | undefined + + const [showScrollButton, setShowScrollButton] = createSignal(false) + const [isButtonHovered, setIsButtonHovered] = createSignal(false) + const [isNearBottom, setIsNearBottom] = createSignal(false) + + function scheduleHide(ms: number) { + return window.setTimeout(() => { + if (!isButtonHovered()) setShowScrollButton(false) + }, ms) + } + + function checkScrollNeed() { + const currentScrollY = window.scrollY + const shouldShow = shouldShowScrollButton({ currentScrollY, lastScrollY, isNearBottom: isNearBottom() }) + + // Update last scroll position + lastScrollY = currentScrollY + + if (shouldShow) { + setShowScrollButton(true) + disposeIfSet(scrollTimeout, clearTimeout) + // Hide button after 3 seconds of no scrolling (unless hovered) + scrollTimeout = scheduleHide(1500) + } else if (!isButtonHovered()) { + // Only hide if not hovered (to prevent disappearing while user is about to click) + setShowScrollButton(false) + disposeIfSet(scrollTimeout, clearTimeout) + } + } + + onMount(() => { + lastScrollY = window.scrollY // Initialize scroll position + + // Create sentinel element + const sentinel = document.createElement("div") + sentinel.style.height = "1px" + sentinel.style.position = "absolute" + sentinel.style.bottom = "100px" + sentinel.style.width = "100%" + sentinel.style.pointerEvents = "none" + document.body.appendChild(sentinel) + + // Create intersection observer + const observer = new IntersectionObserver((entries) => { + setIsNearBottom(entries[0].isIntersecting) + }) + observer.observe(sentinel) + + // Store references for cleanup + scrollSentinel = sentinel + scrollObserver = observer + + checkScrollNeed() + window.addEventListener("scroll", checkScrollNeed) + window.addEventListener("resize", checkScrollNeed) + }) + + onCleanup(() => { + window.removeEventListener("scroll", checkScrollNeed) + window.removeEventListener("resize", checkScrollNeed) + + // Clean up observer and sentinel + disposeIfSet(scrollObserver, (observer) => observer.disconnect()) + disposeIfSet(scrollSentinel, (sentinel) => document.body.removeChild(sentinel)) + disposeIfSet(scrollTimeout, clearTimeout) + }) + + return { + get visible() { + return showScrollButton() + }, + scrollToBottom() { + document.body.scrollIntoView({ behavior: "smooth", block: "end" }) + }, + onMouseEnter() { + setIsButtonHovered(true) + disposeIfSet(scrollTimeout, clearTimeout) + }, + onMouseLeave() { + setIsButtonHovered(false) + if (showScrollButton()) { + scrollTimeout = scheduleHide(3000) + } + }, + } +} + export default function Share(props: { id: string api: string info: Session.Info messages: { locale: string } & Record }) { - let lastScrollY = 0 let hasScrolledToAnchor = false - let scrollTimeout: number | undefined - let scrollSentinel: HTMLElement | undefined - let scrollObserver: IntersectionObserver | undefined const params = new URLSearchParams(window.location.search) const debug = params.get("debug") === "true" - const [showScrollButton, setShowScrollButton] = createSignal(false) - const [isButtonHovered, setIsButtonHovered] = createSignal(false) - const [isNearBottom, setIsNearBottom] = createSignal(false) + const scrollButton = useScrollButton() const [store, setStore] = createStore<{ info?: Session.Info @@ -176,126 +330,7 @@ export default function Share(props: { }) }) - function checkScrollNeed() { - const currentScrollY = window.scrollY - const isScrollingDown = currentScrollY > lastScrollY - const scrolled = currentScrollY > 200 // Show after scrolling 200px - - // Only show when scrolling down, scrolled enough, and not near bottom - const shouldShow = isScrollingDown && scrolled && !isNearBottom() - - // Update last scroll position - lastScrollY = currentScrollY - - if (shouldShow) { - setShowScrollButton(true) - // Clear existing timeout - if (scrollTimeout) { - clearTimeout(scrollTimeout) - } - // Hide button after 3 seconds of no scrolling (unless hovered) - scrollTimeout = window.setTimeout(() => { - if (!isButtonHovered()) { - setShowScrollButton(false) - } - }, 1500) - } else if (!isButtonHovered()) { - // Only hide if not hovered (to prevent disappearing while user is about to click) - setShowScrollButton(false) - if (scrollTimeout) { - clearTimeout(scrollTimeout) - } - } - } - - onMount(() => { - lastScrollY = window.scrollY // Initialize scroll position - - // Create sentinel element - const sentinel = document.createElement("div") - sentinel.style.height = "1px" - sentinel.style.position = "absolute" - sentinel.style.bottom = "100px" - sentinel.style.width = "100%" - sentinel.style.pointerEvents = "none" - document.body.appendChild(sentinel) - - // Create intersection observer - const observer = new IntersectionObserver((entries) => { - setIsNearBottom(entries[0].isIntersecting) - }) - observer.observe(sentinel) - - // Store references for cleanup - scrollSentinel = sentinel - scrollObserver = observer - - checkScrollNeed() - window.addEventListener("scroll", checkScrollNeed) - window.addEventListener("resize", checkScrollNeed) - }) - - onCleanup(() => { - window.removeEventListener("scroll", checkScrollNeed) - window.removeEventListener("resize", checkScrollNeed) - - // Clean up observer and sentinel - if (scrollObserver) { - scrollObserver.disconnect() - } - if (scrollSentinel) { - document.body.removeChild(scrollSentinel) - } - - if (scrollTimeout) { - clearTimeout(scrollTimeout) - } - }) - - const data = createMemo(() => { - const result = { - rootDir: undefined as string | undefined, - created: undefined as number | undefined, - completed: undefined as number | undefined, - messages: [] as MessageWithParts[], - models: {} as Record, - cost: 0, - tokens: { - input: 0, - output: 0, - reasoning: 0, - }, - } - - if (!store.info) return result - - result.created = store.info.time.created - - const msgs = messages() - for (let i = 0; i < msgs.length; i++) { - const msg = msgs[i] - - result.messages.push(msg) - - if (msg.role === "assistant") { - result.cost += msg.cost - result.tokens.input += msg.tokens.input - result.tokens.output += msg.tokens.output - result.tokens.reasoning += msg.tokens.reasoning - - result.models[`${msg.providerID} ${msg.modelID}`] = [msg.providerID, msg.modelID] - - if (msg.path.root) { - result.rootDir = msg.path.root - } - - if (msg.time.completed) { - result.completed = msg.time.completed - } - } - } - return result - }) + const data = createMemo(() => summarizeSession(store.info, messages())) return ( @@ -350,19 +385,7 @@ export default function Share(props: { {(msg, msgIndex) => { - const filteredParts = createMemo(() => - msg.parts.filter((x, index) => { - if (x.type === "step-start" && index > 0) return false - if (x.type === "snapshot") return false - if (x.type === "patch") return false - if (x.type === "step-finish") return false - if (x.type === "text" && x.synthetic === true) return false - if (x.type === "text" && !x.text) return false - if (x.type === "tool" && (x.state.status === "pending" || x.state.status === "running")) - return false - return true - }), - ) + const filteredParts = createMemo(() => msg.parts.filter(isVisiblePart)) return ( @@ -467,27 +490,13 @@ export default function Share(props: { - +