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 { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme" import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta" 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 Duration, Effect } from "effect"
import { import {
type Component, type Component,
createEffect,
createMemo, createMemo,
createResource, createResource,
createSignal, createSignal,
@ -114,6 +115,10 @@ function SessionProviders(props: ParentProps) {
} }
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
const l = useLocation()
createEffect(() => {
console.log("pathname", l.pathname)
})
return ( return (
<AppShellProviders> <AppShellProviders>
<Suspense fallback={<Loading />}> <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 { 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 { 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 { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist" import { useComments } from "@/context/comments"
import { usePermission } from "@/context/permission" import { type SelectedLineRange, selectionFromLines, useFile } from "@/context/file"
import { useLanguage } from "@/context/language" 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 { 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 { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { promptEnabled, promptProbe } from "@/testing/prompt" 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 { 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 { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
import { import {
canNavigateHistoryAtCursor, canNavigateHistoryAtCursor,
navigatePromptHistory, navigatePromptHistory,
prependHistoryEntry,
type PromptHistoryComment, type PromptHistoryComment,
type PromptHistoryEntry, type PromptHistoryEntry,
type PromptHistoryStoredEntry, type PromptHistoryStoredEntry,
prependHistoryEntry,
promptLength, promptLength,
} from "./prompt-input/history" } 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 { PromptImageAttachments } from "./prompt-input/image-attachments"
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder" 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 { interface PromptInputProps {
class?: string class?: string
@ -171,10 +172,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}).activeFileTab }).activeFileTab
const commentInReview = (path: string) => { const commentInReview = (path: string) => {
const sessionID = params.id const id = params.id
if (!sessionID) return false if (!id) return false
const diffs = sync.data.session_diff[sessionID] const diffs = sync.data.session_diff[id]
if (!diffs) return false if (!diffs) return false
return diffs.some((diff) => diff.file === path) return diffs.some((diff) => diff.file === path)
} }
@ -236,10 +237,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return paths 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( const status = createMemo(
() => () =>
sync.data.session_status[params.id ?? ""] ?? { Optional.map(params.id, (id) => sync.data.session_status[id]) ?? {
type: "idle", type: "idle",
}, },
) )
@ -283,7 +284,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
applyingHistory: false, 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) => ({ const motion = (value: number) => ({
opacity: value, opacity: value,
transform: `scale(${0.95 + value * 0.05})`, transform: `scale(${0.95 + value * 0.05})`,
@ -510,9 +514,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
createEffect(() => { createEffect(() => {
params.id if (params.id || !suggest()) return
if (params.id) return
if (!suggest()) return
const interval = setInterval(() => { const interval = setInterval(() => {
setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
}, 6500) }, 6500)
@ -542,16 +544,34 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const agentList = createMemo(() => const agentList = createMemo(() =>
sync.data.agent sync.data.agent
.filter((agent) => !agent.hidden && agent.mode !== "primary") .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 agentNames = createMemo(() => local.agent.list().map((agent) => agent.name))
const handleAtSelect = (option: AtOption | undefined) => { const handleAtSelect = (option: AtOption | undefined) => {
if (!option) return if (!option) return
if (option.type === "agent") { 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 { } 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 agents = agentList()
const open = recent() const open = recent()
const seen = new Set(open) 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 paths = await files.searchFilesAndDirectories(query)
const fileOptions: AtOption[] = paths const fileOptions: AtOption[] = paths
.filter((path) => !seen.has(path)) .filter((path) => !seen.has(path))
@ -780,7 +805,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (content.includes("\u200B")) content = content.replace(/\u200B/g, "") if (content.includes("\u200B")) content = content.replace(/\u200B/g, "")
buffer = "" buffer = ""
if (!content) return 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 position += content.length
} }
@ -1057,20 +1087,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const variants = createMemo(() => ["default", ...local.model.variant.list()]) const variants = createMemo(() => ["default", ...local.model.variant.list()])
const accepting = createMemo(() => { const accepting = createMemo(() => {
const id = params.id if (!params.id) return permission.isAutoAcceptingDirectory(sdk.directory)
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) return permission.isAutoAccepting(params.id, sdk.directory)
return permission.isAutoAccepting(id, sdk.directory)
}) })
const acceptLabel = createMemo(() => const acceptLabel = createMemo(() =>
language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"), language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"),
) )
const toggleAccept = () => { const toggleAccept = () => {
if (!params.id) { const id = params.id
permission.toggleAutoAcceptDirectory(sdk.directory) if (!id) permission.toggleAutoAcceptDirectory(sdk.directory)
return else permission.toggleAutoAccept(id, sdk.directory)
}
permission.toggleAutoAccept(params.id, sdk.directory)
} }
const { abort, handleSubmit } = createPromptSubmit({ const { abort, handleSubmit } = createPromptSubmit({
@ -1503,7 +1529,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<ProviderIcon <ProviderIcon
id={local.model.current()!.provider.id} id={local.model.current()!.provider.id}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150" 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> </Show>
<span class="truncate"> <span class="truncate">
@ -1535,7 +1564,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<ProviderIcon <ProviderIcon
id={local.model.current()!.provider.id} id={local.model.current()!.provider.id}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150" 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> </Show>
<span class="truncate"> <span class="truncate">

View File

@ -1,21 +1,22 @@
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
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 { Accordion } from "@opencode-ai/ui/accordion" import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { File } from "@opencode-ai/ui/file" import { File } from "@opencode-ai/ui/file"
import { Icon } from "@opencode-ai/ui/icon"
import { Markdown } from "@opencode-ai/ui/markdown" import { Markdown } from "@opencode-ai/ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view" 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 { useLanguage } from "@/context/language"
import { useSync } from "@/context/sync"
import { useSessionLayout } from "@/pages/session/session-layout" 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 { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format" import { createSessionContextFormatter } from "./session-context-format"
import { getSessionContextMetrics } from "./session-context-metrics"
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = { const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
system: "var(--syntax-info)", system: "var(--syntax-info)",
@ -94,7 +95,7 @@ export function SessionContextTab() {
const language = useLanguage() const language = useLanguage()
const { params, view } = useSessionLayout() 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( const messages = createMemo(
() => { () => {

View File

@ -1,44 +1,44 @@
import type { Project, UserMessage } from "@opencode-ai/sdk/v2" 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 { useDialog } from "@opencode-ai/ui/context/dialog"
import { import { createAutoScroll } from "@opencode-ai/ui/hooks"
batch, import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
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 { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
import { Tabs } from "@opencode-ai/ui/tabs" 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 { showToast } from "@opencode-ai/ui/toast"
import { base64Encode, checksum } from "@opencode-ai/util/encode" 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 { 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 { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments" 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 { useGlobalSync } from "@/context/global-sync"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal" import { useTerminal } from "@/context/terminal"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers" import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline" import { MessageTimeline } from "@/pages/session/message-timeline"
@ -76,6 +76,8 @@ type SessionHistoryWindowInput = {
* small batches while scrolling upward, and prefetches older history near top. * small batches while scrolling upward, and prefetches older history near top.
*/ */
function createSessionHistoryWindow(input: SessionHistoryWindowInput) { function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
const { params } = useSessionLayout()
const turnInit = 10 const turnInit = 10
const turnBatch = 8 const turnBatch = 8
const turnScrollThreshold = 200 const turnScrollThreshold = 200
@ -93,7 +95,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0) const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
const turnStart = createMemo(() => { const turnStart = createMemo(() => {
const id = input.sessionID() const id = params.id
const len = input.visibleUserMessages().length const len = input.visibleUserMessages().length
if (!id || len <= 0) return 0 if (!id || len <= 0) return 0
if (state.turnID !== id) return initialTurnStart(len) if (state.turnID !== id) return initialTurnStart(len)
@ -103,7 +105,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
}) })
const setTurnStart = (start: number) => { const setTurnStart = (start: number) => {
const id = input.sessionID() const id = params.id
const next = start > 0 ? start : 0 const next = start > 0 ? start : 0
if (!id) { if (!id) {
setState({ turnID: undefined, turnStart: next }) 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. */ /** Button path: reveal all cached turns, fetch older history, reveal one batch. */
const loadAndReveal = async () => { const loadAndReveal = async () => {
const id = input.sessionID() const id = params.id
if (!id) return if (!id) return
const start = turnStart() const start = turnStart()
@ -169,7 +171,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
while (true) { while (true) {
await input.loadMore(id) await input.loadMore(id)
if (input.sessionID() !== id) return if (params.id !== id) return
afterVisible = input.visibleUserMessages().length afterVisible = input.visibleUserMessages().length
const nextLoaded = input.loaded() const nextLoaded = input.loaded()
@ -195,7 +197,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
/** Scroll/prefetch path: fetch older history from server. */ /** Scroll/prefetch path: fetch older history from server. */
const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => { const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
const id = input.sessionID() const id = params.id
if (!id) return if (!id) return
if (!input.historyMore() || input.historyLoading()) return if (!input.historyMore() || input.historyLoading()) return
@ -215,7 +217,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
while (true) { while (true) {
await input.loadMore(id) await input.loadMore(id)
if (input.sessionID() !== id) return if (params.id !== id) return
const nextLoaded = input.loaded() const nextLoaded = input.loaded()
const raw = nextLoaded - loaded const raw = nextLoaded - loaded
@ -279,7 +281,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
createEffect( createEffect(
on( on(
() => [input.sessionID(), input.messagesReady()] as const, () => [params.id, input.messagesReady()] as const,
([id, ready]) => { ([id, ready]) => {
if (!id || !ready) return if (!id || !ready) return
setTurnStart(initialTurnStart(input.visibleUserMessages().length)) setTurnStart(initialTurnStart(input.visibleUserMessages().length))
@ -1618,7 +1620,6 @@ export default function Page() {
const { clearMessageHash, scrollToMessage } = useSessionHashScroll({ const { clearMessageHash, scrollToMessage } = useSessionHashScroll({
sessionKey, sessionKey,
sessionID: () => params.id,
messagesReady, messagesReady,
visibleUserMessages, visibleUserMessages,
turnStart: historyWindow.turnStart, turnStart: historyWindow.turnStart,
@ -1692,48 +1693,51 @@ export default function Page() {
<div class="flex-1 min-h-0 overflow-hidden"> <div class="flex-1 min-h-0 overflow-hidden">
<Switch> <Switch>
<Match when={params.id}> <Match when={params.id}>
<Show when={lastUserMessage()}> {(sessionID) => (
<MessageTimeline <Show when={lastUserMessage()}>
mobileChanges={mobileChanges()} <MessageTimeline
mobileFallback={reviewContent({ sessionID={sessionID()}
diffStyle: "unified", mobileChanges={mobileChanges()}
classes: { mobileFallback={reviewContent({
root: "pb-8", diffStyle: "unified",
header: "px-4", classes: {
container: "px-4", root: "pb-8",
}, header: "px-4",
loadingClass: "px-4 py-4 text-text-weak", container: "px-4",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6", },
})} loadingClass: "px-4 py-4 text-text-weak",
actions={actions} emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
scroll={ui.scroll} })}
onResumeScroll={resumeScroll} actions={actions}
setScrollRef={setScrollRef} scroll={ui.scroll}
onScheduleScrollState={scheduleScrollState} onResumeScroll={resumeScroll}
onAutoScrollHandleScroll={autoScroll.handleScroll} setScrollRef={setScrollRef}
onMarkScrollGesture={markScrollGesture} onScheduleScrollState={scheduleScrollState}
hasScrollGesture={hasScrollGesture} onAutoScrollHandleScroll={autoScroll.handleScroll}
onUserScroll={markUserScroll} onMarkScrollGesture={markScrollGesture}
onTurnBackfillScroll={historyWindow.onScrollerScroll} hasScrollGesture={hasScrollGesture}
onAutoScrollInteraction={autoScroll.handleInteraction} onUserScroll={markUserScroll}
centered={centered()} onTurnBackfillScroll={historyWindow.onScrollerScroll}
setContentRef={(el) => { onAutoScrollInteraction={autoScroll.handleInteraction}
content = el centered={centered()}
autoScroll.contentRef(el) setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
const root = scroller const root = scroller
if (root) scheduleScrollState(root) if (root) scheduleScrollState(root)
}} }}
turnStart={historyWindow.turnStart()} turnStart={historyWindow.turnStart()}
historyMore={historyMore()} historyMore={historyMore()}
historyLoading={historyLoading()} historyLoading={historyLoading()}
onLoadEarlier={() => { onLoadEarlier={() => {
void historyWindow.loadAndReveal() void historyWindow.loadAndReveal()
}} }}
renderedUserMessages={historyWindow.renderedUserMessages()} renderedUserMessages={historyWindow.renderedUserMessages()}
anchor={anchor} anchor={anchor}
/> />
</Show> </Show>
)}
</Match> </Match>
<Match when={true}> <Match when={true}>
<NewSessionView worktree={newSessionWorktree()} /> <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 { 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 { PromptInput } from "@/components/prompt-input"
import type { FollowupDraft } from "@/components/prompt-input/submit"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
import { useSessionKey } from "@/pages/session/session-layout" import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock" import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
import { SessionQuestionDock } from "@/pages/session/composer/session-question-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 { 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 { 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: { export function SessionComposerRegion(props: {
state: SessionComposerState state: SessionComposerState
@ -194,7 +195,6 @@ export function SessionComposerRegion(props: {
> >
<div ref={(el) => setStore("body", el)}> <div ref={(el) => setStore("body", el)}>
<SessionTodoDock <SessionTodoDock
sessionID={route.params.id}
todos={props.state.todos()} todos={props.state.todos()}
collapseLabel={language.t("session.todo.collapse")} collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")} 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 { createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" 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 { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { composerDriver, composerEnabled, composerEvent } from "@/testing/session-composer" 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" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
export const todoState = (input: { export const todoState = (input: {
@ -25,12 +26,12 @@ export const todoState = (input: {
const idle = { type: "idle" as const } const idle = { type: "idle" as const }
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) { export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
const params = useParams()
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const language = useLanguage() const language = useLanguage()
const permission = usePermission() const permission = usePermission()
const { params } = useSessionLayout()
const questionRequest = createMemo((): QuestionRequest | undefined => { const questionRequest = createMemo((): QuestionRequest | undefined => {
return sessionQuestionRequest(sync.data.session, sync.data.question, params.id) return sessionQuestionRequest(sync.data.session, sync.data.question, params.id)
@ -43,8 +44,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
}) })
const blocked = createMemo(() => { const blocked = createMemo(() => {
const id = params.id if (!params.id) return false
if (!id) return false
return !!permissionRequest() || !!questionRequest() 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"), () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
) )
const status = createMemo(() => { const status = createMemo(() => Optional.map(params.id, (id) => sync.data.session_status[id]) ?? idle)
const id = params.id
if (!id) return idle
return sync.data.session_status[id] ?? idle
})
const busy = createMemo(() => status().type !== "idle") const busy = createMemo(() => status().type !== "idle")
const live = createMemo(() => { 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 { useSpring } from "@opencode-ai/ui/motion-spring"
import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough" 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 { createStore } from "solid-js/store"
import { composerEnabled, composerProbe } from "@/testing/session-composer"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { composerEnabled, composerProbe } from "@/testing/session-composer"
import { useSessionLayout } from "../session-layout"
const doneToken = "\u0000done\u0000" const doneToken = "\u0000done\u0000"
const totalToken = "\u0000total\u0000" const totalToken = "\u0000total\u0000"
@ -40,7 +41,6 @@ function dot(status: Todo["status"]) {
} }
export function SessionTodoDock(props: { export function SessionTodoDock(props: {
sessionID?: string
todos: Todo[] todos: Todo[]
collapseLabel: string collapseLabel: string
expandLabel: string expandLabel: string
@ -51,6 +51,7 @@ export function SessionTodoDock(props: {
collapsed: false, collapsed: false,
height: 320, height: 320,
}) })
const { params } = useSessionLayout()
const toggle = () => setStore("collapsed", (value) => !value) 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 turn = createMemo(() => Math.max(0, Math.min(1, value())))
const full = createMemo(() => Math.max(78, store.height)) const full = createMemo(() => Math.max(78, store.height))
const e2e = composerEnabled() const e2e = composerEnabled()
const probe = composerProbe(props.sessionID) const probe = composerProbe(params.id)
let contentRef: HTMLDivElement | undefined let contentRef: HTMLDivElement | undefined
createEffect(() => { createEffect(() => {

View File

@ -1,32 +1,32 @@
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js" import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { createStore, produce } from "solid-js/store" import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
import { useNavigate } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button" 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 { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" 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 { 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 { 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 { 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 { showToast } from "@opencode-ai/ui/toast"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover" import { useNavigate } from "@solidjs/router"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" 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 { 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 { useGlobalSDK } from "@/context/global-sdk"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync" 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 { messageAgentColor } from "@/utils/agent"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
@ -195,6 +195,7 @@ function createTimelineStaging(input: TimelineStageInput) {
} }
export function MessageTimeline(props: { export function MessageTimeline(props: {
sessionID: string
mobileChanges: boolean mobileChanges: boolean
mobileFallback: JSX.Element mobileFallback: JSX.Element
actions?: UserActions actions?: UserActions
@ -226,26 +227,17 @@ export function MessageTimeline(props: {
const settings = useSettings() const settings = useSettings()
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const { params, sessionKey } = useSessionKey() const { sessionKey } = useSessionKey()
const platform = usePlatform() const platform = usePlatform()
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
const sessionID = createMemo(() => params.id) const sessionMessages = createMemo(() => sync.data.message[props.sessionID] ?? emptyMessages)
const sessionMessages = createMemo(() => {
const id = sessionID()
if (!id) return emptyMessages
return sync.data.message[id] ?? emptyMessages
})
const pending = createMemo(() => const pending = createMemo(() =>
sessionMessages().findLast( sessionMessages().findLast(
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
), ),
) )
const sessionStatus = createMemo(() => { const sessionStatus = createMemo(() => sync.data.session_status[props.sessionID] ?? idle)
const id = sessionID()
if (!id) return idle
return sync.data.session_status[id] ?? idle
})
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle") const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent)) const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
@ -300,11 +292,7 @@ export function MessageTimeline(props: {
return undefined return undefined
}) })
const info = createMemo(() => { const info = createMemo(() => sync.session.get(props.sessionID))
const id = sessionID()
if (!id) return
return sync.session.get(id)
})
const titleValue = createMemo(() => info()?.title) const titleValue = createMemo(() => info()?.title)
const shareUrl = createMemo(() => info()?.share?.url) const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") 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 [req, setReq] = createStore({ share: false, unshare: false })
const shareSession = () => { const shareSession = () => {
const id = sessionID() if (req.share) return
if (!id || req.share) return
if (!shareEnabled()) return if (!shareEnabled()) return
setReq("share", true) setReq("share", true)
globalSDK.client.session globalSDK.client.session
.share({ sessionID: id, directory: sdk.directory }) .share({ sessionID: props.sessionID, directory: sdk.directory })
.catch((err: unknown) => { .catch((err: unknown) => {
console.error("Failed to share session", err) console.error("Failed to share session", err)
}) })
@ -353,12 +340,11 @@ export function MessageTimeline(props: {
} }
const unshareSession = () => { const unshareSession = () => {
const id = sessionID() if (req.unshare) return
if (!id || req.unshare) return
if (!shareEnabled()) return if (!shareEnabled()) return
setReq("unshare", true) setReq("unshare", true)
globalSDK.client.session globalSDK.client.session
.unshare({ sessionID: id, directory: sdk.directory }) .unshare({ sessionID: props.sessionID, directory: sdk.directory })
.catch((err: unknown) => { .catch((err: unknown) => {
console.error("Failed to unshare session", err) console.error("Failed to unshare session", err)
}) })
@ -399,7 +385,6 @@ export function MessageTimeline(props: {
) )
const openTitleEditor = () => { const openTitleEditor = () => {
if (!sessionID()) return
setTitle({ editing: true, draft: titleValue() ?? "" }) setTitle({ editing: true, draft: titleValue() ?? "" })
requestAnimationFrame(() => { requestAnimationFrame(() => {
titleRef?.focus() titleRef?.focus()
@ -413,8 +398,6 @@ export function MessageTimeline(props: {
} }
const saveTitleEditor = async () => { const saveTitleEditor = async () => {
const id = sessionID()
if (!id) return
if (title.saving) return if (title.saving) return
const next = title.draft.trim() const next = title.draft.trim()
@ -425,11 +408,11 @@ export function MessageTimeline(props: {
setTitle("saving", true) setTitle("saving", true)
await sdk.client.session await sdk.client.session
.update({ sessionID: id, title: next }) .update({ sessionID: props.sessionID, title: next })
.then(() => { .then(() => {
sync.set( sync.set(
produce((draft) => { 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 if (index !== -1) draft.session[index].title = next
}), }),
) )
@ -444,17 +427,17 @@ export function MessageTimeline(props: {
}) })
} }
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { const navigateAfterSessionRemoval = (id: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return if (props.sessionID !== id) return
if (parentID) { if (parentID) {
navigate(`/${params.dir}/session/${parentID}`) navigate(`../${parentID}`)
return return
} }
if (nextSessionID) { if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`) navigate(`../${nextSessionID}`)
return return
} }
navigate(`/${params.dir}/session`) navigate("../")
} }
const archiveSession = async (sessionID: string) => { const archiveSession = async (sessionID: string) => {
@ -547,7 +530,7 @@ export function MessageTimeline(props: {
const navigateParent = () => { const navigateParent = () => {
const id = parentID() const id = parentID()
if (!id) return if (!id) return
navigate(`/${params.dir}/session/${id}`) navigate(`../${id}`)
} }
function DialogDeleteSession(props: { sessionID: string }) { function DialogDeleteSession(props: { sessionID: string }) {
@ -734,184 +717,180 @@ export function MessageTimeline(props: {
</Show> </Show>
</div> </div>
</div> </div>
<Show when={sessionID()}> <div class="shrink-0 flex items-center gap-3">
{(id) => ( <SessionContextUsage placement="bottom" />
<div class="shrink-0 flex items-center gap-3"> <DropdownMenu
<SessionContextUsage placement="bottom" /> gutter={4}
<DropdownMenu placement="bottom-end"
gutter={4} open={title.menuOpen}
placement="bottom-end" onOpenChange={(open) => {
open={title.menuOpen} setTitle("menuOpen", open)
onOpenChange={(open) => { if (open) return
setTitle("menuOpen", open) }}
if (open) return >
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"bg-surface-base-active": share.open || title.pendingShare,
}}
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen || share.open || title.pendingShare}
ref={(el: HTMLButtonElement) => {
more = el
}}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
}} }}
> >
<DropdownMenu.Trigger <DropdownMenu.Item
as={IconButton} onSelect={() => {
icon="dot-grid" setTitle("pendingRename", true)
variant="ghost" setTitle("menuOpen", false)
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"bg-surface-base-active": share.open || title.pendingShare,
}} }}
aria-label={language.t("common.moreOptions")} >
aria-expanded={title.menuOpen || share.open || title.pendingShare} <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
ref={(el: HTMLButtonElement) => { </DropdownMenu.Item>
more = el <Show when={shareEnabled()}>
}} <DropdownMenu.Item
/> onSelect={() => {
<DropdownMenu.Portal> setTitle({ pendingShare: true, menuOpen: false })
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
}} }}
> >
<DropdownMenu.Item <DropdownMenu.ItemLabel>
onSelect={() => { {language.t("session.share.action.share")}
setTitle("pendingRename", true) </DropdownMenu.ItemLabel>
setTitle("menuOpen", false) </DropdownMenu.Item>
}} </Show>
> <DropdownMenu.Item onSelect={() => void archiveSession(props.sessionID)}>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<Show when={shareEnabled()}> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={props.sessionID} />)}
setTitle({ pendingShare: true, menuOpen: false }) >
}} <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
> </DropdownMenu.Item>
<DropdownMenu.ItemLabel> </DropdownMenu.Content>
{language.t("session.share.action.share")} </DropdownMenu.Portal>
</DropdownMenu.ItemLabel> </DropdownMenu>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<KobaltePopover <KobaltePopover
open={share.open} open={share.open}
anchorRef={() => more} anchorRef={() => more}
placement="bottom-end" placement="bottom-end"
gutter={4} gutter={4}
modal={false} modal={false}
onOpenChange={(open) => { onOpenChange={(open) => {
if (open) setShare("dismiss", null) if (open) setShare("dismiss", null)
setShare("open", open) setShare("open", open)
}}
>
<KobaltePopover.Portal>
<KobaltePopover.Content
data-component="popover-content"
style={{ "min-width": "320px" }}
onEscapeKeyDown={(event) => {
setShare({ dismiss: "escape", open: false })
event.preventDefault()
event.stopPropagation()
}}
onPointerDownOutside={() => {
setShare({ dismiss: "outside", open: false })
}}
onFocusOutside={() => {
setShare({ dismiss: "outside", open: false })
}}
onCloseAutoFocus={(event) => {
if (share.dismiss === "outside") event.preventDefault()
setShare("dismiss", null)
}} }}
> >
<KobaltePopover.Portal> <div class="flex flex-col p-3">
<KobaltePopover.Content <div class="flex flex-col gap-1">
data-component="popover-content" <div class="text-13-medium text-text-strong">
style={{ "min-width": "320px" }} {language.t("session.share.popover.title")}
onEscapeKeyDown={(event) => {
setShare({ dismiss: "escape", open: false })
event.preventDefault()
event.stopPropagation()
}}
onPointerDownOutside={() => {
setShare({ dismiss: "outside", open: false })
}}
onFocusOutside={() => {
setShare({ dismiss: "outside", open: false })
}}
onCloseAutoFocus={(event) => {
if (share.dismiss === "outside") event.preventDefault()
setShare("dismiss", null)
}}
>
<div class="flex flex-col p-3">
<div class="flex flex-col gap-1">
<div class="text-13-medium text-text-strong">
{language.t("session.share.popover.title")}
</div>
<div class="text-12-regular text-text-weak">
{shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")}
</div>
</div>
<div class="mt-3 flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<Button
size="large"
variant="primary"
class="w-full"
onClick={shareSession}
disabled={req.share}
>
{req.share
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
}
>
<div class="flex flex-col gap-2">
<TextField
value={shareUrl() ?? ""}
readOnly
copyable
copyKind="link"
tabIndex={-1}
class="w-full"
/>
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={req.unshare}
>
{req.unshare
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={req.unshare}
>
{language.t("session.share.action.view")}
</Button>
</div>
</div>
</Show>
</div>
</div> </div>
</KobaltePopover.Content> <div class="text-12-regular text-text-weak">
</KobaltePopover.Portal> {shareUrl()
</KobaltePopover> ? language.t("session.share.popover.description.shared")
</div> : language.t("session.share.popover.description.unshared")}
)} </div>
</Show> </div>
<div class="mt-3 flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<Button
size="large"
variant="primary"
class="w-full"
onClick={shareSession}
disabled={req.share}
>
{req.share
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
}
>
<div class="flex flex-col gap-2">
<TextField
value={shareUrl() ?? ""}
readOnly
copyable
copyKind="link"
tabIndex={-1}
class="w-full"
/>
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={req.unshare}
>
{req.unshare
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={req.unshare}
>
{language.t("session.share.action.view")}
</Button>
</div>
</div>
</Show>
</div>
</div>
</KobaltePopover.Content>
</KobaltePopover.Portal>
</KobaltePopover>
</div>
</div> </div>
</div> </div>
</Show> </Show>
@ -999,7 +978,7 @@ export function MessageTimeline(props: {
</div> </div>
</Show> </Show>
<SessionTurn <SessionTurn
sessionID={sessionID() ?? ""} sessionID={props.sessionID}
messageID={messageID} messageID={messageID}
actions={props.actions} actions={props.actions}
active={active()} active={active()}

View File

@ -3,7 +3,7 @@ import { createMemo } from "solid-js"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
export const useSessionKey = () => { export const useSessionKey = () => {
const params = useParams() const params = useParams<{ dir: string; id: string & { __brand: "SessionID" } }>()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
return { params, sessionKey } 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 { 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 FileTree from "@/components/file-tree"
import { FileVisual, SessionContextTab, SortableTab } from "@/components/session"
import { SessionContextUsage } from "@/components/session-context-usage" 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 { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file" import { type SelectedLineRange, useFile } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs" import { FileTabContent } from "@/pages/session/file-tabs"
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
import { setSessionHandoff } from "@/pages/session/handoff" import { setSessionHandoff } from "@/pages/session/handoff"
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { Optional } from "@/utils/optional"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
export function SessionSidePanel(props: { export function SessionSidePanel(props: {
reviewPanel: () => JSX.Element reviewPanel: () => JSX.Element
@ -54,14 +55,13 @@ export function SessionSidePanel(props: {
}) })
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const info = createMemo(() => Optional.map(params.id, (id) => sync.session.get(id)))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.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 reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0) const hasReview = createMemo(() => reviewCount() > 0)
const diffsReady = createMemo(() => { const diffsReady = createMemo(() => {
const id = params.id const id = params.id
if (!id) return true if (!id || !hasReview()) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined return sync.data.session_diff[id] !== undefined
}) })

View File

@ -1,8 +1,15 @@
import { useNavigate } from "@solidjs/router" import type { UserMessage } from "@opencode-ai/sdk/v2"
import { useCommand, type CommandOption } from "@/context/command"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" 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 { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
@ -11,16 +18,10 @@ import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal" 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 { 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 { useSessionLayout } from "@/pages/session/session-layout"
import { Optional } from "@/utils/optional"
import { extractPromptFromParts } from "@/utils/prompt"
export type SessionCommandContext = { export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void navigateMessageByOffset: (offset: number) => void
@ -51,11 +52,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const navigate = useNavigate() const navigate = useNavigate()
const { params, tabs, view } = useSessionLayout() const { params, tabs, view } = useSessionLayout()
const info = () => { const info = () => Optional.map(params.id, (id) => sync.session.get(id))
const id = params.id
if (!id) return
return sync.session.get(id)
}
const hasReview = () => { const hasReview = () => {
const id = params.id const id = params.id
if (!id) return false if (!id) return false
@ -76,12 +74,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const closableTab = tabState.closableTab const closableTab = tabState.closableTab
const idle = { type: "idle" as const } const idle = { type: "idle" as const }
const status = () => sync.data.session_status[params.id ?? ""] ?? idle const status = () => Optional.map(params.id, (id) => sync.data.session_status[id]) ?? idle
const messages = () => { const messages = () => Optional.map(params.id, (id) => sync.data.message[id]) ?? []
const id = params.id
if (!id) return []
return sync.data.message[id] ?? []
}
const userMessages = () => messages().filter((m) => m.role === "user") as UserMessage[] const userMessages = () => messages().filter((m) => m.role === "user") as UserMessage[]
const visibleUserMessages = () => { const visibleUserMessages = () => {
const revert = info()?.revert?.messageID const revert = info()?.revert?.messageID
@ -97,7 +92,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const selectionPreview = (path: string, selection: FileSelection) => { const selectionPreview = (path: string, selection: FileSelection) => {
const content = file.get(path)?.content?.content const content = file.get(path)?.content?.content
if (!content) return undefined 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) => { const addSelectionToContext = (path: string, selection: FileSelection) => {
@ -222,9 +220,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
slash: "unshare", slash: "unshare",
disabled: !params.id || !info()?.share?.url, disabled: !params.id || !info()?.share?.url,
onSelect: async () => { onSelect: async () => {
if (!params.id) return const id = params.id
if (!id) return
await sdk.client.session await sdk.client.session
.unshare({ sessionID: params.id }) .unshare({ sessionID: id })
.then(() => .then(() =>
showToast({ showToast({
title: language.t("toast.session.unshare.success.title"), title: language.t("toast.session.unshare.success.title"),
@ -423,10 +422,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const revert = info()?.revert?.messageID const revert = info()?.revert?.messageID
const message = findLast(userMessages(), (x) => !revert || x.id < revert) const message = findLast(userMessages(), (x) => !revert || x.id < revert)
if (!message) return 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] const parts = sync.data.part[message.id]
if (parts) { if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory }) const restored = extractPromptFromParts(parts, {
directory: sdk.directory,
})
prompt.set(restored) prompt.set(restored)
} }
const priorMessage = findLast(userMessages(), (x) => x.id < message.id) const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
@ -452,7 +456,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
setActiveMessage(lastMsg) setActiveMessage(lastMsg)
return 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) const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
setActiveMessage(priorMsg) setActiveMessage(priorMsg)
}, },

View File

@ -2,10 +2,11 @@ import type { UserMessage } from "@opencode-ai/sdk/v2"
import { useLocation, useNavigate } from "@solidjs/router" import { useLocation, useNavigate } from "@solidjs/router"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { messageIdFromHash } from "./message-id-from-hash" import { messageIdFromHash } from "./message-id-from-hash"
import { useSessionLayout } from "./session-layout"
export const useSessionHashScroll = (input: { export const useSessionHashScroll = (input: {
sessionKey: () => string sessionKey: () => string
sessionID: () => string | undefined
messagesReady: () => boolean messagesReady: () => boolean
visibleUserMessages: () => UserMessage[] visibleUserMessages: () => UserMessage[]
turnStart: () => number turnStart: () => number
@ -28,6 +29,7 @@ export const useSessionHashScroll = (input: {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { params } = useSessionLayout()
const frames = new Set<number>() const frames = new Set<number>()
const queue = (fn: () => void) => { const queue = (fn: () => void) => {
@ -142,13 +144,13 @@ export const useSessionHashScroll = (input: {
createEffect(() => { createEffect(() => {
const hash = location.hash const hash = location.hash
if (!hash) clearing = false if (!hash) clearing = false
if (!input.sessionID() || !input.messagesReady()) return if (!params.id || !input.messagesReady()) return
cancel() cancel()
queue(() => applyHash("auto")) queue(() => applyHash("auto"))
}) })
createEffect(() => { createEffect(() => {
if (!input.sessionID() || !input.messagesReady()) return if (!params.id || !input.messagesReady()) return
visibleUserMessages() visibleUserMessages()
input.turnStart() 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 { DurableObject } from "cloudflare:workers"
import { randomUUID } from "node:crypto" import { randomUUID } from "node:crypto"
import { jwtVerify, createRemoteJWKSet } from "jose"
import { createAppAuth } from "@octokit/auth-app" import { createAppAuth } from "@octokit/auth-app"
import { Octokit } from "@octokit/rest" import { Octokit } from "@octokit/rest"
import { Hono } from "hono"
import { createRemoteJWKSet, jwtVerify } from "jose"
import { Resource } from "sst" import { Resource } from "sst"
type Env = { 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 { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync" 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 { Locale } from "@/util/locale"
import { useKeybind } from "../context/keybind" 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 { useKV } from "../context/kv"
import { useSDK } from "../context/sdk"
import { useTheme } from "../context/theme"
import { createDebouncedSignal } from "../util/signal" import { createDebouncedSignal } from "../util/signal"
import { DialogSessionRename } from "./dialog-session-rename"
import { Spinner } from "./spinner" import { Spinner } from "./spinner"
export function DialogSessionList() { 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 { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync" 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 { Locale } from "@/util/locale"
import { useKeybind } from "../../context/keybind" 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 { useKV } from "../../context/kv"
import { createDebouncedSignal } from "../../util/signal" import { useSDK } from "../../context/sdk"
import { Spinner } from "../spinner" import { useTheme } from "../../context/theme"
import { useToast } from "../../ui/toast" 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 } = {}) { export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) {
const dialog = useDialog() const dialog = useDialog()

View File

@ -1,60 +1,59 @@
import { import type {
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 {
AgentPart, AgentPart,
AssistantMessage, AssistantMessage,
FilePart, FilePart,
Message as MessageType, Message as MessageType,
Part as PartType, Part as PartType,
ReasoningPart,
TextPart,
ToolPart,
UserMessage,
Todo,
QuestionAnswer, QuestionAnswer,
QuestionInfo, QuestionInfo,
ReasoningPart,
TextPart,
Todo,
ToolPart,
UserMessage,
} from "@opencode-ai/sdk/v2" } 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 { useData } from "../context"
import { useFileComponent } from "../context/file"
import { useDialog } from "../context/dialog" import { useDialog } from "../context/dialog"
import { useFileComponent } from "../context/file"
import { type UiI18n, useI18n } from "../context/i18n" import { type UiI18n, useI18n } from "../context/i18n"
import { BasicTool, GenericTool } from "./basic-tool"
import { Accordion } from "./accordion" import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header" import { BasicTool, GenericTool } from "./basic-tool"
import { Card } from "./card" import { Checkbox } from "./checkbox"
import { Collapsible } from "./collapsible" import { Collapsible } from "./collapsible"
import { DiffChanges } from "./diff-changes"
import { FileIcon } from "./file-icon" import { FileIcon } from "./file-icon"
import { Icon } from "./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 { 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 { TextShimmer } from "./text-shimmer"
import { AnimatedCountList } from "./tool-count-summary" import { AnimatedCountList } from "./tool-count-summary"
import { ToolErrorCard } from "./tool-error-card"
import { ToolStatusTitle } from "./tool-status-title" import { ToolStatusTitle } from "./tool-status-title"
import { animate } from "motion" import { Tooltip } from "./tooltip"
import { useLocation } from "@solidjs/router"
import { attached, inline, kind } from "./message-file"
function ShellSubmessage(props: { text: string; animate?: boolean }) { function ShellSubmessage(props: { text: string; animate?: boolean }) {
let widthRef: HTMLSpanElement | undefined 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" }[] = [ const allRefs: { start: number; end: number; type: "file" | "agent" }[] = [
...props.references ...props.references
.filter((r) => r.source?.text?.start !== undefined && r.source?.text?.end !== undefined) .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 ...props.agents
.filter((a) => a.source?.start !== undefined && a.source?.end !== undefined) .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) ].sort((a, b) => a.start - b.start)
const result: HighlightSegment[] = [] const result: HighlightSegment[] = []
@ -1334,7 +1341,10 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
: -1 : -1
if (!(ms >= 0)) return "" if (!(ms >= 0)) return ""
const total = Math.round(ms / 1000) 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 minutes = Math.floor(total / 60)
const seconds = total % 60 const seconds = total % 60
return i18n.t("ui.message.duration.minutesSeconds", { return i18n.t("ui.message.duration.minutesSeconds", {
@ -1475,7 +1485,10 @@ ToolRegistry.register({
<BasicTool <BasicTool
{...props} {...props}
icon="bullet-list" 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}> <Show when={props.output}>
<div data-component="tool-output" data-scrollable> <div data-component="tool-output" data-scrollable>
@ -1974,7 +1987,12 @@ ToolRegistry.register({
<Accordion.Trigger> <Accordion.Trigger>
<div data-slot="apply-patch-trigger-content"> <div data-slot="apply-patch-trigger-content">
<div data-slot="apply-patch-file-info"> <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"> <div data-slot="apply-patch-file-name-container">
<Show when={file.relativePath.includes("/")}> <Show when={file.relativePath.includes("/")}>
<span data-slot="apply-patch-directory">{`\u202A${getDirectory(file.relativePath)}\u202C`}</span> <span data-slot="apply-patch-directory">{`\u202A${getDirectory(file.relativePath)}\u202C`}</span>
@ -2000,7 +2018,12 @@ ToolRegistry.register({
</span> </span>
</Match> </Match>
<Match when={true}> <Match when={true}>
<DiffChanges changes={{ additions: file.additions, deletions: file.deletions }} /> <DiffChanges
changes={{
additions: file.additions,
deletions: file.deletions,
}}
/>
</Match> </Match>
</Switch> </Switch>
<Icon name="chevron-grabber-vertical" size="small" /> <Icon name="chevron-grabber-vertical" size="small" />
@ -2014,8 +2037,14 @@ ToolRegistry.register({
<Dynamic <Dynamic
component={fileComponent} component={fileComponent}
mode="diff" mode="diff"
before={{ name: file.filePath, contents: file.before }} before={{
after={{ name: file.movePath ?? file.filePath, contents: file.after }} name: file.filePath,
contents: file.before,
}}
after={{
name: file.movePath ?? file.filePath,
contents: file.after,
}}
/> />
</div> </div>
</Show> </Show>
@ -2054,7 +2083,12 @@ ToolRegistry.register({
</div> </div>
<div data-slot="message-part-actions"> <div data-slot="message-part-actions">
<Show when={!pending()}> <Show when={!pending()}>
<DiffChanges changes={{ additions: single()!.additions, deletions: single()!.deletions }} /> <DiffChanges
changes={{
additions: single()!.additions,
deletions: single()!.deletions,
}}
/>
</Show> </Show>
</div> </div>
</div> </div>
@ -2080,7 +2114,12 @@ ToolRegistry.register({
</span> </span>
</Match> </Match>
<Match when={true}> <Match when={true}>
<DiffChanges changes={{ additions: single()!.additions, deletions: single()!.deletions }} /> <DiffChanges
changes={{
additions: single()!.additions,
deletions: single()!.deletions,
}}
/>
</Match> </Match>
</Switch> </Switch>
} }
@ -2089,8 +2128,14 @@ ToolRegistry.register({
<Dynamic <Dynamic
component={fileComponent} component={fileComponent}
mode="diff" mode="diff"
before={{ name: single()!.filePath, contents: single()!.before }} before={{
after={{ name: single()!.movePath ?? single()!.filePath, contents: single()!.after }} name: single()!.filePath,
contents: single()!.before,
}}
after={{
name: single()!.movePath ?? single()!.filePath,
contents: single()!.after,
}}
/> />
</div> </div>
</ToolFileAccordion> </ToolFileAccordion>