Compare commits

...

3 Commits

Author SHA1 Message Date
Brendan Allan 56c5cc86c2
Merge branch 'dev' into brendan/better-session-id-handling 2026-03-21 22:15:08 +08:00
Brendan Allan 32d542d1c6
cleanup 2026-03-21 22:12:55 +08:00
Brendan Allan cf3df010ce
app: better session id handling 2026-03-21 21:49:01 +08:00
17 changed files with 587 additions and 504 deletions

View File

@ -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 (
<AppShellProviders>
<Suspense fallback={<Loading />}>

View File

@ -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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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})`,
@ -510,9 +514,7 @@ export const PromptInput: Component<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (props) => {
<ProviderIcon
id={local.model.current()!.provider.id}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
style={{
"will-change": "opacity",
transform: "translateZ(0)",
}}
/>
</Show>
<span class="truncate">
@ -1535,7 +1564,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<ProviderIcon
id={local.model.current()!.provider.id}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
style={{
"will-change": "opacity",
transform: "translateZ(0)",
}}
/>
</Show>
<span class="truncate">

View File

@ -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<SessionContextBreakdownKey, string> = {
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(
() => {

View File

@ -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))
@ -1618,7 +1620,6 @@ export default function Page() {
const { clearMessageHash, scrollToMessage } = useSessionHashScroll({
sessionKey,
sessionID: () => params.id,
messagesReady,
visibleUserMessages,
turnStart: historyWindow.turnStart,
@ -1692,8 +1693,10 @@ export default function Page() {
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id}>
{(sessionID) => (
<Show when={lastUserMessage()}>
<MessageTimeline
sessionID={sessionID()}
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
diffStyle: "unified",
@ -1734,6 +1737,7 @@ export default function Page() {
anchor={anchor}
/>
</Show>
)}
</Match>
<Match when={true}>
<NewSessionView worktree={newSessionWorktree()} />

View File

@ -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: {
>
<div ref={(el) => setStore("body", el)}>
<SessionTodoDock
sessionID={route.params.id}
todos={props.state.todos()}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}

View File

@ -1,14 +1,15 @@
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router"
import { showToast } from "@opencode-ai/ui/toast"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { composerDriver, composerEnabled, composerEvent } from "@/testing/session-composer"
import { Optional } from "@/utils/optional"
import { useSessionLayout } from "../session-layout"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
export const todoState = (input: {
@ -25,12 +26,12 @@ export const todoState = (input: {
const idle = { type: "idle" as const }
export function createSessionComposerState(options?: { closeMs?: number | (() => 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(() => {

View File

@ -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(() => {

View File

@ -1,32 +1,32 @@
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"
@ -195,6 +195,7 @@ function createTimelineStaging(input: TimelineStageInput) {
}
export function MessageTimeline(props: {
sessionID: string
mobileChanges: boolean
mobileFallback: JSX.Element
actions?: UserActions
@ -226,26 +227,17 @@ export function MessageTimeline(props: {
const settings = useSettings()
const dialog = useDialog()
const language = useLanguage()
const { params, sessionKey } = useSessionKey()
const { sessionKey } = useSessionKey()
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(() => sync.data.message[props.sessionID] ?? 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(() => sync.data.session_status[props.sessionID] ?? 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(() => sync.session.get(props.sessionID))
const titleValue = createMemo(() => info()?.title)
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
@ -338,12 +326,11 @@ export function MessageTimeline(props: {
const [req, setReq] = createStore({ share: false, unshare: false })
const shareSession = () => {
const id = sessionID()
if (!id || req.share) return
if (req.share) return
if (!shareEnabled()) return
setReq("share", true)
globalSDK.client.session
.share({ sessionID: id, directory: sdk.directory })
.share({ sessionID: props.sessionID, directory: sdk.directory })
.catch((err: unknown) => {
console.error("Failed to share session", err)
})
@ -353,12 +340,11 @@ export function MessageTimeline(props: {
}
const unshareSession = () => {
const id = sessionID()
if (!id || req.unshare) return
if (req.unshare) return
if (!shareEnabled()) return
setReq("unshare", true)
globalSDK.client.session
.unshare({ sessionID: id, directory: sdk.directory })
.unshare({ sessionID: props.sessionID, directory: sdk.directory })
.catch((err: unknown) => {
console.error("Failed to unshare session", err)
})
@ -399,7 +385,6 @@ export function MessageTimeline(props: {
)
const openTitleEditor = () => {
if (!sessionID()) return
setTitle({ editing: true, draft: titleValue() ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
@ -413,8 +398,6 @@ export function MessageTimeline(props: {
}
const saveTitleEditor = async () => {
const id = sessionID()
if (!id) return
if (title.saving) return
const next = title.draft.trim()
@ -425,11 +408,11 @@ export function MessageTimeline(props: {
setTitle("saving", true)
await sdk.client.session
.update({ sessionID: id, title: next })
.update({ sessionID: props.sessionID, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === id)
const index = draft.session.findIndex((s) => s.id === props.sessionID)
if (index !== -1) draft.session[index].title = next
}),
)
@ -444,17 +427,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 (props.sessionID !== 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 +530,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,8 +717,6 @@ export function MessageTimeline(props: {
</Show>
</div>
</div>
<Show when={sessionID()}>
{(id) => (
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
<DropdownMenu
@ -799,12 +780,12 @@ export function MessageTimeline(props: {
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.Item onSelect={() => void archiveSession(props.sessionID)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={props.sessionID} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
@ -910,8 +891,6 @@ export function MessageTimeline(props: {
</KobaltePopover.Portal>
</KobaltePopover>
</div>
)}
</Show>
</div>
</div>
</Show>
@ -999,7 +978,7 @@ export function MessageTimeline(props: {
</div>
</Show>
<SessionTurn
sessionID={sessionID() ?? ""}
sessionID={props.sessionID}
messageID={messageID}
actions={props.actions}
active={active()}

View File

@ -3,7 +3,7 @@ import { createMemo } from "solid-js"
import { useLayout } from "@/context/layout"
export const useSessionKey = () => {
const params = useParams()
const params = useParams<{ dir: string; id: string & { __brand: "SessionID" } }>()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
return { params, sessionKey }
}

View File

@ -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
})

View File

@ -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) => {
@ -222,9 +220,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"),
@ -423,10 +422,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
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,
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)
@ -452,7 +456,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
setActiveMessage(lastMsg)
return
}
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
await sdk.client.session.revert({
sessionID,
messageID: nextMessage.id,
})
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
setActiveMessage(priorMsg)
},

View File

@ -2,10 +2,11 @@ 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
sessionID: () => string | undefined
messagesReady: () => boolean
visibleUserMessages: () => UserMessage[]
turnStart: () => number
@ -28,6 +29,7 @@ export const useSessionHashScroll = (input: {
const location = useLocation()
const navigate = useNavigate()
const { params } = useSessionLayout()
const frames = new Set<number>()
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()

View File

@ -0,0 +1,11 @@
import { dual } from "effect/Function"
export namespace Optional {
export const map = dual<
<I, O>(f: (value: I) => O) => (opt: I | undefined) => O | undefined,
<I, O>(opt: I | undefined, f: (value: I) => O) => O | undefined
>(2, (opt, f) => {
if (opt === undefined) return undefined
return f(opt)
})
}

View File

@ -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 = {

View File

@ -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() {

View File

@ -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()

View File

@ -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({
<BasicTool
{...props}
icon="bullet-list"
trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }}
trigger={{
title: i18n.t("ui.tool.list"),
subtitle: getDirectory(props.input.path || "/"),
}}
>
<Show when={props.output}>
<div data-component="tool-output" data-scrollable>
@ -1974,7 +1987,12 @@ ToolRegistry.register({
<Accordion.Trigger>
<div data-slot="apply-patch-trigger-content">
<div data-slot="apply-patch-file-info">
<FileIcon node={{ path: file.relativePath, type: "file" }} />
<FileIcon
node={{
path: file.relativePath,
type: "file",
}}
/>
<div data-slot="apply-patch-file-name-container">
<Show when={file.relativePath.includes("/")}>
<span data-slot="apply-patch-directory">{`\u202A${getDirectory(file.relativePath)}\u202C`}</span>
@ -2000,7 +2018,12 @@ ToolRegistry.register({
</span>
</Match>
<Match when={true}>
<DiffChanges changes={{ additions: file.additions, deletions: file.deletions }} />
<DiffChanges
changes={{
additions: file.additions,
deletions: file.deletions,
}}
/>
</Match>
</Switch>
<Icon name="chevron-grabber-vertical" size="small" />
@ -2014,8 +2037,14 @@ ToolRegistry.register({
<Dynamic
component={fileComponent}
mode="diff"
before={{ name: file.filePath, contents: file.before }}
after={{ name: file.movePath ?? file.filePath, contents: file.after }}
before={{
name: file.filePath,
contents: file.before,
}}
after={{
name: file.movePath ?? file.filePath,
contents: file.after,
}}
/>
</div>
</Show>
@ -2054,7 +2083,12 @@ ToolRegistry.register({
</div>
<div data-slot="message-part-actions">
<Show when={!pending()}>
<DiffChanges changes={{ additions: single()!.additions, deletions: single()!.deletions }} />
<DiffChanges
changes={{
additions: single()!.additions,
deletions: single()!.deletions,
}}
/>
</Show>
</div>
</div>
@ -2080,7 +2114,12 @@ ToolRegistry.register({
</span>
</Match>
<Match when={true}>
<DiffChanges changes={{ additions: single()!.additions, deletions: single()!.deletions }} />
<DiffChanges
changes={{
additions: single()!.additions,
deletions: single()!.deletions,
}}
/>
</Match>
</Switch>
}
@ -2089,8 +2128,14 @@ ToolRegistry.register({
<Dynamic
component={fileComponent}
mode="diff"
before={{ name: single()!.filePath, contents: single()!.before }}
after={{ name: single()!.movePath ?? single()!.filePath, contents: single()!.after }}
before={{
name: single()!.filePath,
contents: single()!.before,
}}
after={{
name: single()!.movePath ?? single()!.filePath,
contents: single()!.after,
}}
/>
</div>
</ToolFileAccordion>