From 21e848fa3d4992b26f3650345fecb624442a2ab1 Mon Sep 17 00:00:00 2001 From: eva li Date: Sun, 30 Aug 2026 22:02:46 +0000 Subject: [PATCH 1/6] deployed on docker --- .devcontainer/Dockerfile | 2 ++ .devcontainer/devcontainer-lock.json | 14 ++++++++++++++ .devcontainer/devcontainer.json | 13 +++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer-lock.json create mode 100644 .devcontainer/devcontainer.json 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 From f0c62126e7937beb5f2a039d9da334760495c1d8 Mon Sep 17 00:00:00 2001 From: eva li Date: Fri, 4 Sep 2026 15:39:29 +0000 Subject: [PATCH 2/6] refactor(web): hoist Share's part-visibility filter into isVisiblePart The filteredParts predicate was an anonymous closure nested three levels deep inside Share (For -> filter callback), which qlty attributed to Share's own complexity/return-count score. Hoisting it into a standalone function is behavior-preserving and lets the smells report attribute its 8 early returns to the predicate itself. --- packages/web/src/components/Share.tsx | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index 04514b21..71a9d6cf 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -38,6 +38,17 @@ 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 default function Share(props: { id: string api: string @@ -350,19 +361,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 ( From fffacb8939f64fbb485945642d4daf3bfd408252 Mon Sep 17 00:00:00 2001 From: eva li Date: Fri, 4 Sep 2026 15:39:45 +0000 Subject: [PATCH 3/6] refactor(web): extract session stat aggregation into summarizeSession The data() memo's cost/token/model reducer loop was inlined directly inside Share, adding to its complexity score. Extracting it into a standalone summarizeSession(info, msgs) function is behavior- preserving (same accumulation logic, iterated with for-of instead of an indexed loop since the index was never used). --- packages/web/src/components/Share.tsx | 87 +++++++++++++-------------- 1 file changed, 43 insertions(+), 44 deletions(-) diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index 71a9d6cf..17136274 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -49,6 +49,48 @@ function isVisiblePart(part: MessageV2.Part, index: number): boolean { return true } +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 +} + export default function Share(props: { id: string api: string @@ -263,50 +305,7 @@ export default function Share(props: { } }) - 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 ( From 9c2576a3f7b92c649bc129f88ad25c61503a71bb Mon Sep 17 00:00:00 2001 From: eva li Date: Fri, 4 Sep 2026 15:40:28 +0000 Subject: [PATCH 4/6] refactor(web): extract scroll-to-bottom button state into useScrollButton The floating scroll-to-bottom button's signals, IntersectionObserver sentinel, checkScrollNeed logic, and its onMount/onCleanup pair were all inlined in Share, driving most of its remaining complexity. useScrollButton() encapsulates the same logic unchanged and returns { visible, scrollToBottom, onMouseEnter, onMouseLeave } for the JSX to wire up directly. --- packages/web/src/components/Share.tsx | 220 ++++++++++++++------------ 1 file changed, 119 insertions(+), 101 deletions(-) diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index 17136274..6c020ec9 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -91,24 +91,132 @@ function summarizeSession(info: Session.Info | undefined, msgs: MessageWithParts return result } +// 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 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) + } + }) + + return { + get visible() { + return showScrollButton() + }, + scrollToBottom() { + document.body.scrollIntoView({ behavior: "smooth", block: "end" }) + }, + onMouseEnter() { + setIsButtonHovered(true) + if (scrollTimeout) { + clearTimeout(scrollTimeout) + } + }, + onMouseLeave() { + setIsButtonHovered(false) + if (showScrollButton()) { + scrollTimeout = window.setTimeout(() => { + if (!isButtonHovered()) { + setShowScrollButton(false) + } + }, 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 @@ -229,82 +337,6 @@ 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(() => summarizeSession(store.info, messages())) return ( @@ -465,27 +497,13 @@ export default function Share(props: { - +