diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 9a282bbb70..8597bed720 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -8,10 +8,11 @@ import { Font } from "@opencode-ai/ui/font" import { Splash } from "@opencode-ai/ui/logo" import { ThemeProvider } from "@opencode-ai/ui/theme" import { MetaProvider } from "@solidjs/meta" -import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" +import { type BaseRouterProps, Navigate, Route, Router, useLocation } from "@solidjs/router" import { type Duration, Effect } from "effect" import { type Component, + createEffect, createMemo, createResource, createSignal, @@ -114,6 +115,10 @@ function SessionProviders(props: ParentProps) { } function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { + const l = useLocation() + createEffect(() => { + console.log("pathname", l.pathname) + }) return ( }> diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f3d3e135de..e83c5f4949 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1,61 +1,62 @@ -import { useFilteredList } from "@opencode-ai/ui/hooks" -import { useSpring } from "@opencode-ai/ui/motion-spring" -import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal } from "solid-js" -import { createStore } from "solid-js/store" -import { useLocal } from "@/context/local" -import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" -import { - ContentPart, - DEFAULT_PROMPT, - isPromptEqual, - Prompt, - usePrompt, - ImageAttachmentPart, - AgentPart, - FileAttachmentPart, -} from "@/context/prompt" -import { useLayout } from "@/context/layout" -import { useSDK } from "@/context/sdk" -import { useSync } from "@/context/sync" -import { useComments } from "@/context/comments" import { Button } from "@opencode-ai/ui/button" -import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" -import { Icon } from "@opencode-ai/ui/icon" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { Select } from "@opencode-ai/ui/select" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" +import { useFilteredList } from "@opencode-ai/ui/hooks" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { ImagePreview } from "@opencode-ai/ui/image-preview" +import { useSpring } from "@opencode-ai/ui/motion-spring" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { Select } from "@opencode-ai/ui/select" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { type Component, createEffect, createMemo, createSignal, on, onCleanup, Show } from "solid-js" +import { createStore } from "solid-js/store" import { ModelSelectorPopover } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" -import { useProviders } from "@/hooks/use-providers" import { useCommand } from "@/context/command" -import { Persist, persisted } from "@/utils/persist" -import { usePermission } from "@/context/permission" +import { useComments } from "@/context/comments" +import { type SelectedLineRange, selectionFromLines, useFile } from "@/context/file" import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { useLocal } from "@/context/local" +import { usePermission } from "@/context/permission" import { usePlatform } from "@/context/platform" -import { useSessionLayout } from "@/pages/session/session-layout" +import { + type AgentPart, + type ContentPart, + DEFAULT_PROMPT, + type FileAttachmentPart, + type ImageAttachmentPart, + isPromptEqual, + type Prompt, + usePrompt, +} from "@/context/prompt" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { useProviders } from "@/hooks/use-providers" import { createSessionTabs } from "@/pages/session/helpers" +import { useSessionLayout } from "@/pages/session/session-layout" import { promptEnabled, promptProbe } from "@/testing/prompt" -import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" +import { Optional } from "@/utils/optional" +import { Persist, persisted } from "@/utils/persist" import { createPromptAttachments } from "./prompt-input/attachments" +import { PromptContextItems } from "./prompt-input/context-items" +import { PromptDragOverlay } from "./prompt-input/drag-overlay" +import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { ACCEPTED_FILE_TYPES } from "./prompt-input/files" import { canNavigateHistoryAtCursor, navigatePromptHistory, - prependHistoryEntry, type PromptHistoryComment, type PromptHistoryEntry, type PromptHistoryStoredEntry, + prependHistoryEntry, promptLength, } from "./prompt-input/history" -import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit" -import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" -import { PromptContextItems } from "./prompt-input/context-items" import { PromptImageAttachments } from "./prompt-input/image-attachments" -import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { promptPlaceholder } from "./prompt-input/placeholder" -import { ImagePreview } from "@opencode-ai/ui/image-preview" +import { type AtOption, PromptPopover, type SlashCommand } from "./prompt-input/slash-popover" +import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit" interface PromptInputProps { class?: string @@ -171,10 +172,10 @@ export const PromptInput: Component = (props) => { }).activeFileTab const commentInReview = (path: string) => { - const sessionID = params.id - if (!sessionID) return false + const id = params.id + if (!id) return false - const diffs = sync.data.session_diff[sessionID] + const diffs = sync.data.session_diff[id] if (!diffs) return false return diffs.some((diff) => diff.file === path) } @@ -236,10 +237,10 @@ export const PromptInput: Component = (props) => { return paths }) - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const info = createMemo(() => Optional.map(params.id, (s) => sync.session.get(s))) const status = createMemo( () => - sync.data.session_status[params.id ?? ""] ?? { + Optional.map(params.id, (id) => sync.data.session_status[id]) ?? { type: "idle", }, ) @@ -283,7 +284,10 @@ export const PromptInput: Component = (props) => { applyingHistory: false, }) - const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) + const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { + visualDuration: 0.2, + bounce: 0, + }) const motion = (value: number) => ({ opacity: value, transform: `scale(${0.95 + value * 0.05})`, @@ -306,9 +310,9 @@ export const PromptInput: Component = (props) => { }) const hasUserPrompt = createMemo(() => { - const sessionID = params.id - if (!sessionID) return false - const messages = sync.data.message[sessionID] + const id = params.id + if (!id) return false + const messages = sync.data.message[id] if (!messages) return false return messages.some((m) => m.role === "user") }) @@ -510,9 +514,7 @@ export const PromptInput: Component = (props) => { } createEffect(() => { - params.id - if (params.id) return - if (!suggest()) return + if (params.id || !suggest()) return const interval = setInterval(() => { setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) }, 6500) @@ -542,16 +544,34 @@ export const PromptInput: Component = (props) => { const agentList = createMemo(() => sync.data.agent .filter((agent) => !agent.hidden && agent.mode !== "primary") - .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), + .map( + (agent): AtOption => ({ + type: "agent", + name: agent.name, + display: agent.name, + }), + ), ) const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name)) const handleAtSelect = (option: AtOption | undefined) => { if (!option) return if (option.type === "agent") { - addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 }) + addPart({ + type: "agent", + name: option.name, + content: "@" + option.name, + start: 0, + end: 0, + }) } else { - addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) + addPart({ + type: "file", + path: option.path, + content: "@" + option.path, + start: 0, + end: 0, + }) } } @@ -571,7 +591,12 @@ export const PromptInput: Component = (props) => { const agents = agentList() const open = recent() const seen = new Set(open) - const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) + const pinned: AtOption[] = open.map((path) => ({ + type: "file", + path, + display: path, + recent: true, + })) const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) @@ -780,7 +805,12 @@ export const PromptInput: Component = (props) => { if (content.includes("\u200B")) content = content.replace(/\u200B/g, "") buffer = "" if (!content) return - parts.push({ type: "text", content, start: position, end: position + content.length }) + parts.push({ + type: "text", + content, + start: position, + end: position + content.length, + }) position += content.length } @@ -1057,20 +1087,16 @@ export const PromptInput: Component = (props) => { const variants = createMemo(() => ["default", ...local.model.variant.list()]) const accepting = createMemo(() => { - const id = params.id - if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) - return permission.isAutoAccepting(id, sdk.directory) + if (!params.id) return permission.isAutoAcceptingDirectory(sdk.directory) + return permission.isAutoAccepting(params.id, sdk.directory) }) const acceptLabel = createMemo(() => language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"), ) const toggleAccept = () => { - if (!params.id) { - permission.toggleAutoAcceptDirectory(sdk.directory) - return - } - - permission.toggleAutoAccept(params.id, sdk.directory) + const id = params.id + if (!id) permission.toggleAutoAcceptDirectory(sdk.directory) + else permission.toggleAutoAccept(id, sdk.directory) } const { abort, handleSubmit } = createPromptSubmit({ @@ -1503,7 +1529,10 @@ export const PromptInput: Component = (props) => { @@ -1535,7 +1564,10 @@ export const PromptInput: Component = (props) => { diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 9aa101bdb9..b1a3935431 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -1,21 +1,22 @@ -import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" -import type { JSX } from "solid-js" -import { useSync } from "@/context/sync" -import { checksum } from "@opencode-ai/util/encode" -import { findLast } from "@opencode-ai/util/array" -import { same } from "@/utils/same" -import { Icon } from "@opencode-ai/ui/icon" +import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import { Accordion } from "@opencode-ai/ui/accordion" -import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { File } from "@opencode-ai/ui/file" +import { Icon } from "@opencode-ai/ui/icon" import { Markdown } from "@opencode-ai/ui/markdown" import { ScrollView } from "@opencode-ai/ui/scroll-view" -import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" +import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" +import { findLast } from "@opencode-ai/util/array" +import { checksum } from "@opencode-ai/util/encode" +import type { JSX } from "solid-js" +import { createEffect, createMemo, For, on, onCleanup, Show } from "solid-js" import { useLanguage } from "@/context/language" +import { useSync } from "@/context/sync" import { useSessionLayout } from "@/pages/session/session-layout" -import { getSessionContextMetrics } from "./session-context-metrics" +import { Optional } from "@/utils/optional" +import { same } from "@/utils/same" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { createSessionContextFormatter } from "./session-context-format" +import { getSessionContextMetrics } from "./session-context-metrics" const BREAKDOWN_COLOR: Record = { system: "var(--syntax-info)", @@ -94,7 +95,7 @@ export function SessionContextTab() { const language = useLanguage() const { params, view } = useSessionLayout() - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const info = createMemo(() => Optional.map(params.id, (s) => sync.session.get(s))) const messages = createMemo( () => { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 6d29170081..47d34c9881 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,44 +1,44 @@ import type { Project, UserMessage } from "@opencode-ai/sdk/v2" +import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { - batch, - onCleanup, - Show, - Match, - Switch, - createMemo, - createEffect, - createComputed, - on, - onMount, - untrack, -} from "solid-js" -import { createMediaQuery } from "@solid-primitives/media" -import { createResizeObserver } from "@solid-primitives/resize-observer" -import { useLocal } from "@/context/local" -import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" -import { createStore } from "solid-js/store" +import { createAutoScroll } from "@opencode-ai/ui/hooks" +import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Select } from "@opencode-ai/ui/select" import { Tabs } from "@opencode-ai/ui/tabs" -import { createAutoScroll } from "@opencode-ai/ui/hooks" -import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" -import { Button } from "@opencode-ai/ui/button" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode, checksum } from "@opencode-ai/util/encode" +import { createMediaQuery } from "@solid-primitives/media" +import { createResizeObserver } from "@solid-primitives/resize-observer" import { useNavigate, useSearchParams } from "@solidjs/router" +import { + batch, + createComputed, + createEffect, + createMemo, + Match, + on, + onCleanup, + onMount, + Show, + Switch, + untrack, +} from "solid-js" +import { createStore } from "solid-js/store" +import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { NewSessionView, SessionHeader } from "@/components/session" import { useComments } from "@/context/comments" -import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch" +import { type FileSelection, type SelectedLineRange, selectionFromLines, useFile } from "@/context/file" import { useGlobalSync } from "@/context/global-sync" +import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" +import { useLocal } from "@/context/local" import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" -import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers" import { MessageTimeline } from "@/pages/session/message-timeline" @@ -76,6 +76,8 @@ type SessionHistoryWindowInput = { * small batches while scrolling upward, and prefetches older history near top. */ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { + const { params } = useSessionLayout() + const turnInit = 10 const turnBatch = 8 const turnScrollThreshold = 200 @@ -93,7 +95,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0) const turnStart = createMemo(() => { - const id = input.sessionID() + const id = params.id const len = input.visibleUserMessages().length if (!id || len <= 0) return 0 if (state.turnID !== id) return initialTurnStart(len) @@ -103,7 +105,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { }) const setTurnStart = (start: number) => { - const id = input.sessionID() + const id = params.id const next = start > 0 ? start : 0 if (!id) { setState({ turnID: undefined, turnStart: next }) @@ -153,7 +155,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { /** Button path: reveal all cached turns, fetch older history, reveal one batch. */ const loadAndReveal = async () => { - const id = input.sessionID() + const id = params.id if (!id) return const start = turnStart() @@ -169,7 +171,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { while (true) { await input.loadMore(id) - if (input.sessionID() !== id) return + if (params.id !== id) return afterVisible = input.visibleUserMessages().length const nextLoaded = input.loaded() @@ -195,7 +197,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { /** Scroll/prefetch path: fetch older history from server. */ const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => { - const id = input.sessionID() + const id = params.id if (!id) return if (!input.historyMore() || input.historyLoading()) return @@ -215,7 +217,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { while (true) { await input.loadMore(id) - if (input.sessionID() !== id) return + if (params.id !== id) return const nextLoaded = input.loaded() const raw = nextLoaded - loaded @@ -279,7 +281,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { createEffect( on( - () => [input.sessionID(), input.messagesReady()] as const, + () => [params.id, input.messagesReady()] as const, ([id, ready]) => { if (!id || !ready) return setTurnStart(initialTurnStart(input.visibleUserMessages().length)) diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index a5263cd743..813d489cfd 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -1,18 +1,19 @@ -import { Show, createEffect, createMemo, onCleanup } from "solid-js" -import { createStore } from "solid-js/store" import { useSpring } from "@opencode-ai/ui/motion-spring" +import { createEffect, createMemo, onCleanup, Show } from "solid-js" +import { createStore } from "solid-js/store" import { PromptInput } from "@/components/prompt-input" +import type { FollowupDraft } from "@/components/prompt-input/submit" import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" -import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" -import { useSessionKey } from "@/pages/session/session-layout" +import type { SessionComposerState } from "@/pages/session/composer/session-composer-state" +import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock" import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock" import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock" -import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock" import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock" -import type { SessionComposerState } from "@/pages/session/composer/session-composer-state" import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" -import type { FollowupDraft } from "@/components/prompt-input/submit" +import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" + +import { useSessionKey } from "@/pages/session/session-layout" export function SessionComposerRegion(props: { state: SessionComposerState @@ -194,7 +195,6 @@ export function SessionComposerRegion(props: { >
setStore("body", el)}> number) }) { - const params = useParams() const sdk = useSDK() const sync = useSync() const globalSync = useGlobalSync() const language = useLanguage() const permission = usePermission() + const { params } = useSessionLayout() const questionRequest = createMemo((): QuestionRequest | undefined => { return sessionQuestionRequest(sync.data.session, sync.data.question, params.id) @@ -43,8 +44,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => }) const blocked = createMemo(() => { - const id = params.id - if (!id) return false + if (!params.id) return false return !!permissionRequest() || !!questionRequest() }) @@ -101,11 +101,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() => () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"), ) - const status = createMemo(() => { - const id = params.id - if (!id) return idle - return sync.data.session_status[id] ?? idle - }) + const status = createMemo(() => Optional.map(params.id, (id) => sync.data.session_status[id]) ?? idle) const busy = createMemo(() => status().type !== "idle") const live = createMemo(() => { diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index c16ac83993..ab4ab8d1e9 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -6,10 +6,11 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { useSpring } from "@opencode-ai/ui/motion-spring" import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough" -import { Index, createEffect, createMemo, on, onCleanup } from "solid-js" +import { createEffect, createMemo, Index, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" -import { composerEnabled, composerProbe } from "@/testing/session-composer" import { useLanguage } from "@/context/language" +import { composerEnabled, composerProbe } from "@/testing/session-composer" +import { useSessionLayout } from "../session-layout" const doneToken = "\u0000done\u0000" const totalToken = "\u0000total\u0000" @@ -40,7 +41,6 @@ function dot(status: Todo["status"]) { } export function SessionTodoDock(props: { - sessionID?: string todos: Todo[] collapseLabel: string expandLabel: string @@ -51,6 +51,7 @@ export function SessionTodoDock(props: { collapsed: false, height: 320, }) + const { params } = useSessionLayout() const toggle = () => setStore("collapsed", (value) => !value) @@ -81,7 +82,7 @@ export function SessionTodoDock(props: { const turn = createMemo(() => Math.max(0, Math.min(1, value()))) const full = createMemo(() => Math.max(78, store.height)) const e2e = composerEnabled() - const probe = composerProbe(props.sessionID) + const probe = composerProbe(params.id) let contentRef: HTMLDivElement | undefined createEffect(() => { diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 74f2e8c2c1..ad874254e4 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1,34 +1,35 @@ -import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js" -import { createStore, produce } from "solid-js/store" -import { useNavigate } from "@solidjs/router" +import { Popover as KobaltePopover } from "@kobalte/core/popover" +import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Dialog } from "@opencode-ai/ui/dialog" import { InlineInput } from "@opencode-ai/ui/inline-input" -import { Spinner } from "@opencode-ai/ui/spinner" -import { SessionTurn } from "@opencode-ai/ui/session-turn" import { ScrollView } from "@opencode-ai/ui/scroll-view" +import { SessionTurn } from "@opencode-ai/ui/session-turn" +import { Spinner } from "@opencode-ai/ui/spinner" import { TextField } from "@opencode-ai/ui/text-field" -import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" import { Binary } from "@opencode-ai/util/binary" import { getFilename } from "@opencode-ai/util/path" -import { Popover as KobaltePopover } from "@kobalte/core/popover" -import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" +import { useNavigate } from "@solidjs/router" +import { createEffect, createMemo, For, Index, type JSX, on, onCleanup, Show } from "solid-js" +import { createStore, produce } from "solid-js/store" import { SessionContextUsage } from "@/components/session-context-usage" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useLanguage } from "@/context/language" -import { useSessionKey } from "@/pages/session/session-layout" import { useGlobalSDK } from "@/context/global-sdk" +import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" -import { useSettings } from "@/context/settings" import { useSDK } from "@/context/sdk" +import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" +import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "@/pages/session/message-gesture" +import { useSessionKey } from "@/pages/session/session-layout" import { messageAgentColor } from "@/utils/agent" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" +import { Optional } from "@/utils/optional" type MessageComment = { path: string @@ -230,22 +231,13 @@ export function MessageTimeline(props: { const platform = usePlatform() const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) - const sessionID = createMemo(() => params.id) - const sessionMessages = createMemo(() => { - const id = sessionID() - if (!id) return emptyMessages - return sync.data.message[id] ?? emptyMessages - }) + const sessionMessages = createMemo(() => Optional.map(params.id, (id) => sync.data.message[id]) ?? emptyMessages) const pending = createMemo(() => sessionMessages().findLast( (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", ), ) - const sessionStatus = createMemo(() => { - const id = sessionID() - if (!id) return idle - return sync.data.session_status[id] ?? idle - }) + const sessionStatus = createMemo(() => Optional.map(params.id, (id) => sync.data.session_status[id]) ?? idle) const working = createMemo(() => !!pending() || sessionStatus().type !== "idle") const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent)) @@ -300,11 +292,7 @@ export function MessageTimeline(props: { return undefined }) - const info = createMemo(() => { - const id = sessionID() - if (!id) return - return sync.session.get(id) - }) + const info = createMemo(() => Optional.map(params.id, (id) => sync.session.get(id))) const titleValue = createMemo(() => info()?.title) const shareUrl = createMemo(() => info()?.share?.url) const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") @@ -338,7 +326,7 @@ export function MessageTimeline(props: { const [req, setReq] = createStore({ share: false, unshare: false }) const shareSession = () => { - const id = sessionID() + const id = params.id if (!id || req.share) return if (!shareEnabled()) return setReq("share", true) @@ -353,7 +341,7 @@ export function MessageTimeline(props: { } const unshareSession = () => { - const id = sessionID() + const id = params.id if (!id || req.unshare) return if (!shareEnabled()) return setReq("unshare", true) @@ -399,7 +387,7 @@ export function MessageTimeline(props: { ) const openTitleEditor = () => { - if (!sessionID()) return + if (!params.id) return setTitle({ editing: true, draft: titleValue() ?? "" }) requestAnimationFrame(() => { titleRef?.focus() @@ -413,7 +401,7 @@ export function MessageTimeline(props: { } const saveTitleEditor = async () => { - const id = sessionID() + const id = params.id if (!id) return if (title.saving) return @@ -444,17 +432,17 @@ export function MessageTimeline(props: { }) } - const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { - if (params.id !== sessionID) return + const navigateAfterSessionRemoval = (id: string, parentID?: string, nextSessionID?: string) => { + if (params.id !== id) return if (parentID) { - navigate(`/${params.dir}/session/${parentID}`) + navigate(`../${parentID}`) return } if (nextSessionID) { - navigate(`/${params.dir}/session/${nextSessionID}`) + navigate(`../${nextSessionID}`) return } - navigate(`/${params.dir}/session`) + navigate("../") } const archiveSession = async (sessionID: string) => { @@ -547,7 +535,7 @@ export function MessageTimeline(props: { const navigateParent = () => { const id = parentID() if (!id) return - navigate(`/${params.dir}/session/${id}`) + navigate(`../${id}`) } function DialogDeleteSession(props: { sessionID: string }) { @@ -734,7 +722,7 @@ export function MessageTimeline(props: {
- + {(id) => (
@@ -999,7 +987,7 @@ export function MessageTimeline(props: {
{ - const params = useParams() + const params = useParams<{ dir: string; id: string & { __brand: "SessionID" } }>() const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) return { params, sessionKey } } diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 3b8b0c96bf..a3c7ee8a4e 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -1,30 +1,31 @@ -import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js" -import { createStore } from "solid-js/store" -import { createMediaQuery } from "@solid-primitives/media" -import { Tabs } from "@opencode-ai/ui/tabs" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { ResizeHandle } from "@opencode-ai/ui/resize-handle" -import { Mark } from "@opencode-ai/ui/logo" -import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" -import type { DragEvent } from "@thisbeyond/solid-dnd" -import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Mark } from "@opencode-ai/ui/logo" +import { ResizeHandle } from "@opencode-ai/ui/resize-handle" +import { Tabs } from "@opencode-ai/ui/tabs" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { createMediaQuery } from "@solid-primitives/media" +import type { DragEvent } from "@thisbeyond/solid-dnd" +import { closestCenter, DragDropProvider, DragDropSensors, DragOverlay, SortableProvider } from "@thisbeyond/solid-dnd" +import { createEffect, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js" +import { createStore } from "solid-js/store" +import { DialogSelectFile } from "@/components/dialog-select-file" import FileTree from "@/components/file-tree" +import { FileVisual, SessionContextTab, SortableTab } from "@/components/session" import { SessionContextUsage } from "@/components/session-context-usage" -import { DialogSelectFile } from "@/components/dialog-select-file" -import { SessionContextTab, SortableTab, FileVisual } from "@/components/session" import { useCommand } from "@/context/command" -import { useFile, type SelectedLineRange } from "@/context/file" +import { type SelectedLineRange, useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useSync } from "@/context/sync" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" -import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers" import { setSessionHandoff } from "@/pages/session/handoff" +import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers" import { useSessionLayout } from "@/pages/session/session-layout" +import { Optional } from "@/utils/optional" +import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" export function SessionSidePanel(props: { reviewPanel: () => JSX.Element @@ -54,14 +55,13 @@ export function SessionSidePanel(props: { }) const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) + const info = createMemo(() => Optional.map(params.id, (id) => sync.session.get(id))) + const diffs = createMemo(() => Optional.map(params.id, (id) => sync.data.session_diff[id]) ?? []) const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) const hasReview = createMemo(() => reviewCount() > 0) const diffsReady = createMemo(() => { const id = params.id - if (!id) return true - if (!hasReview()) return true + if (!id || !hasReview()) return true return sync.data.session_diff[id] !== undefined }) diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 1a2e777f52..e08a61d390 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -1,8 +1,15 @@ -import { useNavigate } from "@solidjs/router" -import { useCommand, type CommandOption } from "@/context/command" +import type { UserMessage } from "@opencode-ai/sdk/v2" import { useDialog } from "@opencode-ai/ui/context/dialog" import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" -import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file" +import { showToast } from "@opencode-ai/ui/toast" +import { findLast } from "@opencode-ai/util/array" +import { useNavigate } from "@solidjs/router" +import { DialogFork } from "@/components/dialog-fork" +import { DialogSelectFile } from "@/components/dialog-select-file" +import { DialogSelectMcp } from "@/components/dialog-select-mcp" +import { DialogSelectModel } from "@/components/dialog-select-model" +import { type CommandOption, useCommand } from "@/context/command" +import { type FileSelection, type SelectedLineRange, selectionFromLines, useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useLocal } from "@/context/local" @@ -11,16 +18,10 @@ import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" -import { DialogSelectFile } from "@/components/dialog-select-file" -import { DialogSelectModel } from "@/components/dialog-select-model" -import { DialogSelectMcp } from "@/components/dialog-select-mcp" -import { DialogFork } from "@/components/dialog-fork" -import { showToast } from "@opencode-ai/ui/toast" -import { findLast } from "@opencode-ai/util/array" import { createSessionTabs } from "@/pages/session/helpers" -import { extractPromptFromParts } from "@/utils/prompt" -import { UserMessage } from "@opencode-ai/sdk/v2" import { useSessionLayout } from "@/pages/session/session-layout" +import { Optional } from "@/utils/optional" +import { extractPromptFromParts } from "@/utils/prompt" export type SessionCommandContext = { navigateMessageByOffset: (offset: number) => void @@ -51,11 +52,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const navigate = useNavigate() const { params, tabs, view } = useSessionLayout() - const info = () => { - const id = params.id - if (!id) return - return sync.session.get(id) - } + const info = () => Optional.map(params.id, (id) => sync.session.get(id)) + const hasReview = () => { const id = params.id if (!id) return false @@ -76,12 +74,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const closableTab = tabState.closableTab const idle = { type: "idle" as const } - const status = () => sync.data.session_status[params.id ?? ""] ?? idle - const messages = () => { - const id = params.id - if (!id) return [] - return sync.data.message[id] ?? [] - } + const status = () => Optional.map(params.id, (id) => sync.data.session_status[id]) ?? idle + const messages = () => Optional.map(params.id, (id) => sync.data.message[id]) ?? [] + const userMessages = () => messages().filter((m) => m.role === "user") as UserMessage[] const visibleUserMessages = () => { const revert = info()?.revert?.messageID @@ -97,7 +92,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const selectionPreview = (path: string, selection: FileSelection) => { const content = file.get(path)?.content?.content if (!content) return undefined - return previewSelectedLines(content, { start: selection.startLine, end: selection.endLine }) + return previewSelectedLines(content, { + start: selection.startLine, + end: selection.endLine, + }) } const addSelectionToContext = (path: string, selection: FileSelection) => { @@ -128,8 +126,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const permissionsCommand = withCategory(language.t("command.category.permissions")) const isAutoAcceptActive = () => { - const sessionID = params.id - if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory) + const id = params.id + if (id) return permission.isAutoAccepting(id, sdk.directory) return permission.isAutoAcceptingDirectory(sdk.directory) } command.register("session", () => { @@ -148,7 +146,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => { slash: "share", disabled: !params.id, onSelect: async () => { - if (!params.id) return + const id = params.id + if (!id) return const write = (value: string) => { const body = typeof document === "undefined" ? undefined : document.body @@ -200,7 +199,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { } const url = await sdk.client.session - .share({ sessionID: params.id }) + .share({ sessionID: id }) .then((res) => res.data?.share?.url) .catch(() => undefined) if (!url) { @@ -222,9 +221,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => { slash: "unshare", disabled: !params.id || !info()?.share?.url, onSelect: async () => { - if (!params.id) return + const id = params.id + if (!id) return await sdk.client.session - .unshare({ sessionID: params.id }) + .unshare({ sessionID: id }) .then(() => showToast({ title: language.t("toast.session.unshare.success.title"), @@ -391,12 +391,12 @@ export const useSessionCommands = (actions: SessionCommandContext) => { keybind: "mod+shift+a", disabled: false, onSelect: () => { - const sessionID = params.id - if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory) + const id = params.id + if (id) permission.toggleAutoAccept(id, sdk.directory) else permission.toggleAutoAcceptDirectory(sdk.directory) - const active = sessionID - ? permission.isAutoAccepting(sessionID, sdk.directory) + const active = id + ? permission.isAutoAccepting(id, sdk.directory) : permission.isAutoAcceptingDirectory(sdk.directory) showToast({ title: active @@ -415,18 +415,23 @@ export const useSessionCommands = (actions: SessionCommandContext) => { slash: "undo", disabled: !params.id || visibleUserMessages().length === 0, onSelect: async () => { - const sessionID = params.id - if (!sessionID) return + const id = params.id + if (!id) return if (status().type !== "idle") { - await sdk.client.session.abort({ sessionID }).catch(() => {}) + await sdk.client.session.abort({ sessionID: id }).catch(() => {}) } const revert = info()?.revert?.messageID const message = findLast(userMessages(), (x) => !revert || x.id < revert) if (!message) return - await sdk.client.session.revert({ sessionID, messageID: message.id }) + await sdk.client.session.revert({ + sessionID: id, + messageID: message.id, + }) const parts = sync.data.part[message.id] if (parts) { - const restored = extractPromptFromParts(parts, { directory: sdk.directory }) + const restored = extractPromptFromParts(parts, { + directory: sdk.directory, + }) prompt.set(restored) } const priorMessage = findLast(userMessages(), (x) => x.id < message.id) @@ -440,19 +445,22 @@ export const useSessionCommands = (actions: SessionCommandContext) => { slash: "redo", disabled: !params.id || !info()?.revert?.messageID, onSelect: async () => { - const sessionID = params.id - if (!sessionID) return + const id = params.id + if (!id) return const revertMessageID = info()?.revert?.messageID if (!revertMessageID) return const nextMessage = userMessages().find((x) => x.id > revertMessageID) if (!nextMessage) { - await sdk.client.session.unrevert({ sessionID }) + await sdk.client.session.unrevert({ sessionID: id }) prompt.reset() const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID) setActiveMessage(lastMsg) return } - await sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) + await sdk.client.session.revert({ + sessionID: id, + messageID: nextMessage.id, + }) const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id) setActiveMessage(priorMsg) }, @@ -464,8 +472,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => { slash: "compact", disabled: !params.id || visibleUserMessages().length === 0, onSelect: async () => { - const sessionID = params.id - if (!sessionID) return + const id = params.id + if (!id) return const model = local.model.current() if (!model) { showToast({ @@ -475,7 +483,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { return } await sdk.client.session.summarize({ - sessionID, + sessionID: id, modelID: model.id, providerID: model.provider.id, }) diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index 5fadb1f22a..d6ab273dce 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -2,6 +2,7 @@ import type { UserMessage } from "@opencode-ai/sdk/v2" import { useLocation, useNavigate } from "@solidjs/router" import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { messageIdFromHash } from "./message-id-from-hash" +import { useSessionLayout } from "./session-layout" export const useSessionHashScroll = (input: { sessionKey: () => string @@ -28,6 +29,7 @@ export const useSessionHashScroll = (input: { const location = useLocation() const navigate = useNavigate() + const { params } = useSessionLayout() const frames = new Set() const queue = (fn: () => void) => { @@ -142,13 +144,13 @@ export const useSessionHashScroll = (input: { createEffect(() => { const hash = location.hash if (!hash) clearing = false - if (!input.sessionID() || !input.messagesReady()) return + if (!params.id || !input.messagesReady()) return cancel() queue(() => applyHash("auto")) }) createEffect(() => { - if (!input.sessionID() || !input.messagesReady()) return + if (!params.id || !input.messagesReady()) return visibleUserMessages() input.turnStart() diff --git a/packages/app/src/utils/optional.ts b/packages/app/src/utils/optional.ts new file mode 100644 index 0000000000..2069ae7c04 --- /dev/null +++ b/packages/app/src/utils/optional.ts @@ -0,0 +1,11 @@ +import { dual } from "effect/Function" + +export namespace Optional { + export const map = dual< + (f: (value: I) => O) => (opt: I | undefined) => O | undefined, + (opt: I | undefined, f: (value: I) => O) => O | undefined + >(2, (opt, f) => { + if (opt === undefined) return undefined + return f(opt) + }) +} diff --git a/packages/function/src/api.ts b/packages/function/src/api.ts index 54b93ad715..78fc5b0423 100644 --- a/packages/function/src/api.ts +++ b/packages/function/src/api.ts @@ -1,9 +1,9 @@ -import { Hono } from "hono" import { DurableObject } from "cloudflare:workers" import { randomUUID } from "node:crypto" -import { jwtVerify, createRemoteJWKSet } from "jose" import { createAppAuth } from "@octokit/auth-app" import { Octokit } from "@octokit/rest" +import { Hono } from "hono" +import { createRemoteJWKSet, jwtVerify } from "jose" import { Resource } from "sst" type Env = { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 775969bfcb..ca323a48ab 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -1,15 +1,15 @@ -import { useDialog } from "@tui/ui/dialog" -import { DialogSelect } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { createMemo, createSignal, createResource, onMount, Show } from "solid-js" +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect } from "@tui/ui/dialog-select" +import { createMemo, createResource, createSignal, onMount } from "solid-js" import { Locale } from "@/util/locale" import { useKeybind } from "../context/keybind" -import { useTheme } from "../context/theme" -import { useSDK } from "../context/sdk" -import { DialogSessionRename } from "./dialog-session-rename" import { useKV } from "../context/kv" +import { useSDK } from "../context/sdk" +import { useTheme } from "../context/theme" import { createDebouncedSignal } from "../util/signal" +import { DialogSessionRename } from "./dialog-session-rename" import { Spinner } from "./spinner" export function DialogSessionList() { diff --git a/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx index 326f094a56..3dea597d91 100644 --- a/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx @@ -1,17 +1,17 @@ -import { useDialog } from "@tui/ui/dialog" -import { DialogSelect } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { createMemo, createSignal, createResource, onMount, Show } from "solid-js" +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect } from "@tui/ui/dialog-select" +import { createMemo, createResource, createSignal, onMount } from "solid-js" import { Locale } from "@/util/locale" import { useKeybind } from "../../context/keybind" -import { useTheme } from "../../context/theme" -import { useSDK } from "../../context/sdk" -import { DialogSessionRename } from "../dialog-session-rename" import { useKV } from "../../context/kv" -import { createDebouncedSignal } from "../../util/signal" -import { Spinner } from "../spinner" +import { useSDK } from "../../context/sdk" +import { useTheme } from "../../context/theme" import { useToast } from "../../ui/toast" +import { createDebouncedSignal } from "../../util/signal" +import { DialogSessionRename } from "../dialog-session-rename" +import { Spinner } from "../spinner" export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) { const dialog = useDialog() diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index e8c9dcf950..c8c75a782b 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1,60 +1,59 @@ -import { - Component, - createEffect, - createMemo, - createSignal, - For, - Match, - onMount, - Show, - Switch, - onCleanup, - Index, - type JSX, -} from "solid-js" -import { createStore } from "solid-js/store" -import stripAnsi from "strip-ansi" -import { Dynamic } from "solid-js/web" -import { +import type { AgentPart, AssistantMessage, FilePart, Message as MessageType, Part as PartType, - ReasoningPart, - TextPart, - ToolPart, - UserMessage, - Todo, QuestionAnswer, QuestionInfo, + ReasoningPart, + TextPart, + Todo, + ToolPart, + UserMessage, } from "@opencode-ai/sdk/v2" +import { checksum } from "@opencode-ai/util/encode" +import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path" +import { useLocation } from "@solidjs/router" +import { animate } from "motion" +import { + type Component, + createEffect, + createMemo, + createSignal, + For, + Index, + type JSX, + Match, + onCleanup, + onMount, + Show, + Switch, +} from "solid-js" +import { createStore } from "solid-js/store" +import { Dynamic } from "solid-js/web" +import stripAnsi from "strip-ansi" import { useData } from "../context" -import { useFileComponent } from "../context/file" import { useDialog } from "../context/dialog" +import { useFileComponent } from "../context/file" import { type UiI18n, useI18n } from "../context/i18n" -import { BasicTool, GenericTool } from "./basic-tool" import { Accordion } from "./accordion" -import { StickyAccordionHeader } from "./sticky-accordion-header" -import { Card } from "./card" +import { BasicTool, GenericTool } from "./basic-tool" +import { Checkbox } from "./checkbox" import { Collapsible } from "./collapsible" +import { DiffChanges } from "./diff-changes" import { FileIcon } from "./file-icon" import { Icon } from "./icon" -import { ToolErrorCard } from "./tool-error-card" -import { Checkbox } from "./checkbox" -import { DiffChanges } from "./diff-changes" -import { Markdown } from "./markdown" -import { ImagePreview } from "./image-preview" -import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path" -import { checksum } from "@opencode-ai/util/encode" -import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" +import { ImagePreview } from "./image-preview" +import { Markdown } from "./markdown" +import { attached, inline, kind } from "./message-file" +import { StickyAccordionHeader } from "./sticky-accordion-header" import { TextShimmer } from "./text-shimmer" import { AnimatedCountList } from "./tool-count-summary" +import { ToolErrorCard } from "./tool-error-card" import { ToolStatusTitle } from "./tool-status-title" -import { animate } from "motion" -import { useLocation } from "@solidjs/router" -import { attached, inline, kind } from "./message-file" +import { Tooltip } from "./tooltip" function ShellSubmessage(props: { text: string; animate?: boolean }) { let widthRef: HTMLSpanElement | undefined @@ -1087,10 +1086,18 @@ function HighlightedText(props: { text: string; references: FilePart[]; agents: const allRefs: { start: number; end: number; type: "file" | "agent" }[] = [ ...props.references .filter((r) => r.source?.text?.start !== undefined && r.source?.text?.end !== undefined) - .map((r) => ({ start: r.source!.text!.start, end: r.source!.text!.end, type: "file" as const })), + .map((r) => ({ + start: r.source!.text!.start, + end: r.source!.text!.end, + type: "file" as const, + })), ...props.agents .filter((a) => a.source?.start !== undefined && a.source?.end !== undefined) - .map((a) => ({ start: a.source!.start, end: a.source!.end, type: "agent" as const })), + .map((a) => ({ + start: a.source!.start, + end: a.source!.end, + type: "agent" as const, + })), ].sort((a, b) => a.start - b.start) const result: HighlightSegment[] = [] @@ -1334,7 +1341,10 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { : -1 if (!(ms >= 0)) return "" const total = Math.round(ms / 1000) - if (total < 60) return i18n.t("ui.message.duration.seconds", { count: numfmt().format(total) }) + if (total < 60) + return i18n.t("ui.message.duration.seconds", { + count: numfmt().format(total), + }) const minutes = Math.floor(total / 60) const seconds = total % 60 return i18n.t("ui.message.duration.minutesSeconds", { @@ -1475,7 +1485,10 @@ ToolRegistry.register({
@@ -1974,7 +1987,12 @@ ToolRegistry.register({
- +
{`\u202A${getDirectory(file.relativePath)}\u202C`} @@ -2000,7 +2018,12 @@ ToolRegistry.register({ - + @@ -2014,8 +2037,14 @@ ToolRegistry.register({
@@ -2054,7 +2083,12 @@ ToolRegistry.register({
- +
@@ -2080,7 +2114,12 @@ ToolRegistry.register({ - + } @@ -2089,8 +2128,14 @@ ToolRegistry.register({