From acd91bddf7e83591d60f6801b7d34685a7f3f256 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 04:58:01 -0600 Subject: wip(desktop): progress --- packages/ui/src/components/dialog.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) (limited to 'packages/ui/src/components') diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index 47d6af42e..40a6ac83d 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -20,6 +20,14 @@ export function Dialog(props: DialogProps) { ...(props.classList ?? {}), [props.class ?? ""]: !!props.class, }} + onOpenAutoFocus={(e) => { + const target = e.currentTarget as HTMLElement | null + const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null + if (autofocusEl) { + e.preventDefault() + autofocusEl.focus() + } + }} >
-- cgit v1.2.3 From ece3bfd93d88584dbab4e680a1cc56efc5113b9b Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:06:14 -0600 Subject: wip(desktop): progress --- packages/ui/src/components/message-part.css | 12 ++++++++++++ 1 file changed, 12 insertions(+) (limited to 'packages/ui/src/components') diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 9d4214bae..b66ef1d27 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -107,15 +107,19 @@ display: flex; align-items: center; justify-content: space-between; + gap: 8px; width: 100%; [data-slot="message-part-title-area"] { + flex-grow: 1; display: flex; align-items: center; gap: 8px; + min-width: 0; } [data-slot="message-part-title"] { + flex-shrink: 0; font-family: var(--font-family-sans); font-size: var(--font-size-small); font-style: normal; @@ -128,14 +132,22 @@ [data-slot="message-part-path"] { display: flex; + flex-grow: 1; + min-width: 0; } [data-slot="message-part-directory"] { color: var(--text-weak); + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + direction: rtl; + text-align: left; } [data-slot="message-part-filename"] { color: var(--text-strong); + flex-shrink: 0; } [data-slot="message-part-actions"] { -- cgit v1.2.3 From d81d63045ac619bc9201df4ae2e003a2d08596d1 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:18:39 -0600 Subject: wip(desktop): session turn state consolidation --- packages/ui/src/components/session-turn.tsx | 620 ++++++++++++++-------------- 1 file changed, 303 insertions(+), 317 deletions(-) (limited to 'packages/ui/src/components') diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index ad2e6c36e..e6654f480 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -40,6 +40,9 @@ export function SessionTurn( .sort((a, b) => a.id.localeCompare(b.id)), ) const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID)) + + if (!message()) return null + const status = createMemo( () => data.store.session_status[props.sessionID] ?? { @@ -49,379 +52,362 @@ export function SessionTurn( const working = createMemo(() => status()?.type !== "idle") let scrollRef: HTMLDivElement | undefined - const [state, setState] = createStore({ + + const assistantMessages = createMemo(() => { + return messages()?.filter((m) => m.role === "assistant" && m.parentID == message()!.id) as AssistantMessage[] + }) + const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1)) + const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id])) + const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) + const parts = createMemo(() => data.store.part[message()!.id]) + const lastTextPart = createMemo(() => + assistantMessageParts() + .filter((p) => p?.type === "text") + ?.at(-1), + ) + const summary = createMemo(() => message()!.summary?.body ?? lastTextPart()?.text) + const lastTextPartShown = createMemo(() => !message()!.summary?.body && (lastTextPart()?.text?.length ?? 0) > 0) + + const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id])) + const currentTask = createMemo( + () => + assistantParts().findLast( + (p) => + p && + p.type === "tool" && + p.tool === "task" && + p.state && + "metadata" in p.state && + p.state.metadata && + p.state.metadata.sessionId && + p.state.status === "running", + ) as ToolPart, + ) + const resolvedParts = createMemo(() => { + let resolved = assistantParts() + const task = currentTask() + if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) { + const msgs = data.store.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant") + resolved = msgs?.flatMap((m) => data.store.part[m.id]) ?? assistantParts() + } + return resolved + }) + const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) + const rawStatus = createMemo(() => { + const last = lastPart() + if (!last) return undefined + + if (last.type === "tool") { + switch (last.tool) { + case "task": + return "Delegating work" + case "todowrite": + case "todoread": + return "Planning next steps" + case "read": + return "Gathering context" + case "list": + case "grep": + case "glob": + return "Searching the codebase" + case "webfetch": + return "Searching the web" + case "edit": + case "write": + return "Making edits" + case "bash": + return "Running commands" + default: + break + } + } else if (last.type === "reasoning") { + const text = last.text ?? "" + const match = text.trimStart().match(/^\*\*(.+?)\*\*/) + if (match) return `Thinking · ${match[1].trim()}` + return "Thinking" + } else if (last.type === "text") { + return "Gathering thoughts" + } + return undefined + }) + + function duration() { + const completed = lastAssistantMessage()?.time.completed + const from = DateTime.fromMillis(message()!.time.created) + const to = completed ? DateTime.fromMillis(completed) : DateTime.now() + const interval = Interval.fromDateTimes(from, to) + const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] + + return interval.toDuration(unit).normalize().toHuman({ + notation: "compact", + unitDisplay: "narrow", + compactDisplay: "short", + showZeros: false, + }) + } + + const [store, setStore] = createStore({ stickyTitleRef: undefined as HTMLDivElement | undefined, stickyTriggerRef: undefined as HTMLDivElement | undefined, userScrolled: false, stickyHeaderHeight: 0, scrollY: 0, autoScrolling: false, + status: rawStatus(), + stepsExpanded: true, + duration: duration(), + lastStatusChange: Date.now(), + statusTimeout: undefined as number | undefined, }) function handleScroll() { if (!scrollRef) return // prevents scroll loops if (working() && scrollRef.scrollTop < 100) return - setState("scrollY", scrollRef.scrollTop) - if (state.autoScrolling) return + setStore("scrollY", scrollRef.scrollTop) + if (store.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { - setState("userScrolled", true) + setStore("userScrolled", true) } } function handleInteraction() { if (working()) { - setState("userScrolled", true) + setStore("userScrolled", true) } } function scrollToBottom() { - if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return - setState("autoScrolling", true) + if (!scrollRef || store.userScrolled || !working() || store.autoScrolling) return + setStore("autoScrolling", true) requestAnimationFrame(() => { scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" }) requestAnimationFrame(() => { - setState("autoScrolling", false) + setStore("autoScrolling", false) }) }) } createEffect(() => { if (!working()) { - setState("userScrolled", false) + setStore("userScrolled", false) } }) createResizeObserver( - () => state.stickyTitleRef, + () => store.stickyTitleRef, ({ height }) => { - const triggerHeight = state.stickyTriggerRef?.offsetHeight ?? 0 - setState("stickyHeaderHeight", height + triggerHeight + 8) + const triggerHeight = store.stickyTriggerRef?.offsetHeight ?? 0 + setStore("stickyHeaderHeight", height + triggerHeight + 8) }, ) createResizeObserver( - () => state.stickyTriggerRef, + () => store.stickyTriggerRef, ({ height }) => { - const titleHeight = state.stickyTitleRef?.offsetHeight ?? 0 - setState("stickyHeaderHeight", titleHeight + height + 8) + const titleHeight = store.stickyTitleRef?.offsetHeight ?? 0 + setStore("stickyHeaderHeight", titleHeight + height + 8) }, ) - return ( -
-
-
- - {(message) => { - const assistantMessages = createMemo(() => { - return messages()?.filter( - (m) => m.role === "assistant" && m.parentID == message().id, - ) as AssistantMessage[] - }) - const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1)) - const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id])) - const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) - const parts = createMemo(() => data.store.part[message().id]) - const lastTextPart = createMemo(() => - assistantMessageParts() - .filter((p) => p?.type === "text") - ?.at(-1), - ) - const summary = createMemo(() => message().summary?.body ?? lastTextPart()?.text) - const lastTextPartShown = createMemo( - () => !message().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0, - ) - - const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id])) - const currentTask = createMemo( - () => - assistantParts().findLast( - (p) => - p && - p.type === "tool" && - p.tool === "task" && - p.state && - "metadata" in p.state && - p.state.metadata && - p.state.metadata.sessionId && - p.state.status === "running", - ) as ToolPart, - ) - const resolvedParts = createMemo(() => { - let resolved = assistantParts() - const task = currentTask() - if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) { - const messages = data.store.message[task.state.metadata.sessionId as string]?.filter( - (m) => m.role === "assistant", - ) - resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? assistantParts() - } - return resolved - }) - const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) - const rawStatus = createMemo(() => { - const last = lastPart() - if (!last) return undefined - - if (last.type === "tool") { - switch (last.tool) { - case "task": - return "Delegating work" - case "todowrite": - case "todoread": - return "Planning next steps" - case "read": - return "Gathering context" - case "list": - case "grep": - case "glob": - return "Searching the codebase" - case "webfetch": - return "Searching the web" - case "edit": - case "write": - return "Making edits" - case "bash": - return "Running commands" - default: - break - } - } else if (last.type === "reasoning") { - const text = last.text ?? "" - const match = text.trimStart().match(/^\*\*(.+?)\*\*/) - if (match) return `Thinking · ${match[1].trim()}` - return "Thinking" - } else if (last.type === "text") { - return "Gathering thoughts" - } - return undefined - }) - - function duration() { - const completed = lastAssistantMessage()?.time.completed - const from = DateTime.fromMillis(message()!.time.created) - const to = completed ? DateTime.fromMillis(completed) : DateTime.now() - const interval = Interval.fromDateTimes(from, to) - const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] - - return interval.toDuration(unit).normalize().toHuman({ - notation: "compact", - unitDisplay: "narrow", - compactDisplay: "short", - showZeros: false, - }) - } - - createEffect(() => { - lastPart() - scrollToBottom() - }) - - const [store, setStore] = createStore({ - status: rawStatus(), - stepsExpanded: true, - duration: duration(), - }) + createEffect(() => { + lastPart() + scrollToBottom() + }) - createEffect(() => { - const timer = setInterval(() => { - setStore("duration", duration()) - }, 1000) - onCleanup(() => clearInterval(timer)) - }) + createEffect(() => { + const timer = setInterval(() => { + setStore("duration", duration()) + }, 1000) + onCleanup(() => clearInterval(timer)) + }) - let lastStatusChange = Date.now() - let statusTimeout: number | undefined - createEffect(() => { - const newStatus = rawStatus() - if (newStatus === store.status || !newStatus) return + createEffect(() => { + const newStatus = rawStatus() + if (newStatus === store.status || !newStatus) return - const timeSinceLastChange = Date.now() - lastStatusChange + const timeSinceLastChange = Date.now() - store.lastStatusChange - if (timeSinceLastChange >= 2500) { - setStore("status", newStatus) - lastStatusChange = Date.now() - if (statusTimeout) { - clearTimeout(statusTimeout) - statusTimeout = undefined - } - } else { - if (statusTimeout) clearTimeout(statusTimeout) - statusTimeout = setTimeout(() => { - setStore("status", rawStatus()) - lastStatusChange = Date.now() - statusTimeout = undefined - }, 2500 - timeSinceLastChange) as unknown as number - } - }) + if (timeSinceLastChange >= 2500) { + setStore("status", newStatus) + setStore("lastStatusChange", Date.now()) + if (store.statusTimeout) { + clearTimeout(store.statusTimeout) + setStore("statusTimeout", undefined) + } + } else { + if (store.statusTimeout) clearTimeout(store.statusTimeout) + setStore( + "statusTimeout", + setTimeout(() => { + setStore("status", rawStatus()) + setStore("lastStatusChange", Date.now()) + setStore("statusTimeout", undefined) + }, 2500 - timeSinceLastChange) as unknown as number, + ) + } + }) - createEffect((prev) => { - const isWorking = working() - if (prev && !isWorking && !state.userScrolled) { - setStore("stepsExpanded", false) - } - return isWorking - }, working()) + createEffect((prev) => { + const isWorking = working() + if (prev && !isWorking && !store.userScrolled) { + setStore("stepsExpanded", false) + } + return isWorking + }, working()) - return ( -
- {/* Title (sticky) */} -
setState("stickyTitleRef", el)} data-slot="session-turn-sticky-title"> -
-
- - - - - -

{message().summary?.title}

-
-
-
-
-
- {/* User Message */} -
- -
- {/* Trigger (sticky) */} -
setState("stickyTriggerRef", el)} data-slot="session-turn-response-trigger"> - +
+ {/* Response */} + +
+ + {(assistantMessage) => { + const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) + const last = createMemo(() => + parts() + .filter((p) => p?.type === "text") + .at(-1), + ) + return ( - {store.status ?? "Considering next steps"} - Hide steps - Show steps + + p?.id !== last()?.id)} /> + + + + - · - {store.duration} - - -
- {/* Response */} - -
- - {(assistantMessage) => { - const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) - const last = createMemo(() => - parts() - .filter((p) => p?.type === "text") - .at(-1), - ) - return ( - - - p?.id !== last()?.id)} - /> - - - - - - ) - }} - - - - {error()?.data?.message as string} - - -
-
- {/* Summary */} - -
-
-

- - Summary - Response - -

- - {(summary) => ( - - )} - -
- - - {(diff) => ( - - - -
-
- -
- - {getDirectory(diff.file)}‎ - - {getFilename(diff.file)} -
-
-
- - -
-
-
-
- - - -
- )} -
-
-
-
- - - {error()?.data?.message as string} - + ) + }} + + + + {error()?.data?.message as string} + + +
+
+ {/* Summary */} + +
+
+

+ + Summary + Response + +

+ + {(summary) => ( + + )}
- ) - }} - + + + {(diff) => ( + + + +
+
+ +
+ + {getDirectory(diff.file)}‎ + + {getFilename(diff.file)} +
+
+
+ + +
+
+
+
+ + + +
+ )} +
+
+
+
+ + + {error()?.data?.message as string} + + +
{props.children}
-- cgit v1.2.3 From 40572eeba4b94d411934c7ff95ce51ba88c9562f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:19:20 -0600 Subject: wip(desktop): progress --- packages/ui/src/components/list.css | 1 + packages/ui/src/components/list.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) (limited to 'packages/ui/src/components') diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index cd9e73d1d..368065e53 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -112,6 +112,7 @@ padding: 4px 10px; align-items: center; color: var(--text-strong); + scroll-margin-top: 28px; /* text-14-medium */ font-family: var(--font-family-sans); diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 7ec6e159d..0ed745f32 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -79,7 +79,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) return } const element = scrollRef()?.querySelector(`[data-key="${active()}"]`) - element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) + element?.scrollIntoView({ block: "center", behavior: "smooth" }) }) const handleSelect = (item: T | undefined, index: number) => { -- cgit v1.2.3 From 82c4755fb01f3ae821569af80d1b8670fe69fe56 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:26:32 -0600 Subject: wip(desktop): progress --- packages/desktop/src/pages/session.tsx | 24 +++++++++++++----------- packages/ui/src/components/session-turn.tsx | 3 --- 2 files changed, 13 insertions(+), 14 deletions(-) (limited to 'packages/ui/src/components') diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index caea614c9..66a2fcec7 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -500,17 +500,19 @@ export default function Page() { onMessageSelect={setActiveMessage} wide={wide()} /> - 1 ? "pr-6 pl-18" : "px-6"), - }} - /> + + 1 ? "pr-6 pl-18" : "px-6"), + }} + /> +
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index e6654f480..281b2a03b 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -40,9 +40,6 @@ export function SessionTurn( .sort((a, b) => a.id.localeCompare(b.id)), ) const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID)) - - if (!message()) return null - const status = createMemo( () => data.store.session_status[props.sessionID] ?? { -- cgit v1.2.3 From d31824320e9632cf7671210ce4d3c190a5bf1187 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 05:42:25 -0600 Subject: Revert "wip(desktop): session turn state consolidation" This reverts commit 453f862616dc4d3ac90680581cde279e118b0da1. --- packages/ui/src/components/session-turn.tsx | 617 ++++++++++++++-------------- 1 file changed, 317 insertions(+), 300 deletions(-) (limited to 'packages/ui/src/components') diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 281b2a03b..ad2e6c36e 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -49,362 +49,379 @@ export function SessionTurn( const working = createMemo(() => status()?.type !== "idle") let scrollRef: HTMLDivElement | undefined - - const assistantMessages = createMemo(() => { - return messages()?.filter((m) => m.role === "assistant" && m.parentID == message()!.id) as AssistantMessage[] - }) - const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1)) - const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id])) - const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) - const parts = createMemo(() => data.store.part[message()!.id]) - const lastTextPart = createMemo(() => - assistantMessageParts() - .filter((p) => p?.type === "text") - ?.at(-1), - ) - const summary = createMemo(() => message()!.summary?.body ?? lastTextPart()?.text) - const lastTextPartShown = createMemo(() => !message()!.summary?.body && (lastTextPart()?.text?.length ?? 0) > 0) - - const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id])) - const currentTask = createMemo( - () => - assistantParts().findLast( - (p) => - p && - p.type === "tool" && - p.tool === "task" && - p.state && - "metadata" in p.state && - p.state.metadata && - p.state.metadata.sessionId && - p.state.status === "running", - ) as ToolPart, - ) - const resolvedParts = createMemo(() => { - let resolved = assistantParts() - const task = currentTask() - if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) { - const msgs = data.store.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant") - resolved = msgs?.flatMap((m) => data.store.part[m.id]) ?? assistantParts() - } - return resolved - }) - const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) - const rawStatus = createMemo(() => { - const last = lastPart() - if (!last) return undefined - - if (last.type === "tool") { - switch (last.tool) { - case "task": - return "Delegating work" - case "todowrite": - case "todoread": - return "Planning next steps" - case "read": - return "Gathering context" - case "list": - case "grep": - case "glob": - return "Searching the codebase" - case "webfetch": - return "Searching the web" - case "edit": - case "write": - return "Making edits" - case "bash": - return "Running commands" - default: - break - } - } else if (last.type === "reasoning") { - const text = last.text ?? "" - const match = text.trimStart().match(/^\*\*(.+?)\*\*/) - if (match) return `Thinking · ${match[1].trim()}` - return "Thinking" - } else if (last.type === "text") { - return "Gathering thoughts" - } - return undefined - }) - - function duration() { - const completed = lastAssistantMessage()?.time.completed - const from = DateTime.fromMillis(message()!.time.created) - const to = completed ? DateTime.fromMillis(completed) : DateTime.now() - const interval = Interval.fromDateTimes(from, to) - const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] - - return interval.toDuration(unit).normalize().toHuman({ - notation: "compact", - unitDisplay: "narrow", - compactDisplay: "short", - showZeros: false, - }) - } - - const [store, setStore] = createStore({ + const [state, setState] = createStore({ stickyTitleRef: undefined as HTMLDivElement | undefined, stickyTriggerRef: undefined as HTMLDivElement | undefined, userScrolled: false, stickyHeaderHeight: 0, scrollY: 0, autoScrolling: false, - status: rawStatus(), - stepsExpanded: true, - duration: duration(), - lastStatusChange: Date.now(), - statusTimeout: undefined as number | undefined, }) function handleScroll() { if (!scrollRef) return // prevents scroll loops if (working() && scrollRef.scrollTop < 100) return - setStore("scrollY", scrollRef.scrollTop) - if (store.autoScrolling) return + setState("scrollY", scrollRef.scrollTop) + if (state.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { - setStore("userScrolled", true) + setState("userScrolled", true) } } function handleInteraction() { if (working()) { - setStore("userScrolled", true) + setState("userScrolled", true) } } function scrollToBottom() { - if (!scrollRef || store.userScrolled || !working() || store.autoScrolling) return - setStore("autoScrolling", true) + if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return + setState("autoScrolling", true) requestAnimationFrame(() => { scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" }) requestAnimationFrame(() => { - setStore("autoScrolling", false) + setState("autoScrolling", false) }) }) } createEffect(() => { if (!working()) { - setStore("userScrolled", false) + setState("userScrolled", false) } }) createResizeObserver( - () => store.stickyTitleRef, + () => state.stickyTitleRef, ({ height }) => { - const triggerHeight = store.stickyTriggerRef?.offsetHeight ?? 0 - setStore("stickyHeaderHeight", height + triggerHeight + 8) + const triggerHeight = state.stickyTriggerRef?.offsetHeight ?? 0 + setState("stickyHeaderHeight", height + triggerHeight + 8) }, ) createResizeObserver( - () => store.stickyTriggerRef, + () => state.stickyTriggerRef, ({ height }) => { - const titleHeight = store.stickyTitleRef?.offsetHeight ?? 0 - setStore("stickyHeaderHeight", titleHeight + height + 8) + const titleHeight = state.stickyTitleRef?.offsetHeight ?? 0 + setState("stickyHeaderHeight", titleHeight + height + 8) }, ) - createEffect(() => { - lastPart() - scrollToBottom() - }) + return ( +
+
+
+ + {(message) => { + const assistantMessages = createMemo(() => { + return messages()?.filter( + (m) => m.role === "assistant" && m.parentID == message().id, + ) as AssistantMessage[] + }) + const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1)) + const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id])) + const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) + const parts = createMemo(() => data.store.part[message().id]) + const lastTextPart = createMemo(() => + assistantMessageParts() + .filter((p) => p?.type === "text") + ?.at(-1), + ) + const summary = createMemo(() => message().summary?.body ?? lastTextPart()?.text) + const lastTextPartShown = createMemo( + () => !message().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0, + ) - createEffect(() => { - const timer = setInterval(() => { - setStore("duration", duration()) - }, 1000) - onCleanup(() => clearInterval(timer)) - }) + const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id])) + const currentTask = createMemo( + () => + assistantParts().findLast( + (p) => + p && + p.type === "tool" && + p.tool === "task" && + p.state && + "metadata" in p.state && + p.state.metadata && + p.state.metadata.sessionId && + p.state.status === "running", + ) as ToolPart, + ) + const resolvedParts = createMemo(() => { + let resolved = assistantParts() + const task = currentTask() + if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) { + const messages = data.store.message[task.state.metadata.sessionId as string]?.filter( + (m) => m.role === "assistant", + ) + resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? assistantParts() + } + return resolved + }) + const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) + const rawStatus = createMemo(() => { + const last = lastPart() + if (!last) return undefined - createEffect(() => { - const newStatus = rawStatus() - if (newStatus === store.status || !newStatus) return + if (last.type === "tool") { + switch (last.tool) { + case "task": + return "Delegating work" + case "todowrite": + case "todoread": + return "Planning next steps" + case "read": + return "Gathering context" + case "list": + case "grep": + case "glob": + return "Searching the codebase" + case "webfetch": + return "Searching the web" + case "edit": + case "write": + return "Making edits" + case "bash": + return "Running commands" + default: + break + } + } else if (last.type === "reasoning") { + const text = last.text ?? "" + const match = text.trimStart().match(/^\*\*(.+?)\*\*/) + if (match) return `Thinking · ${match[1].trim()}` + return "Thinking" + } else if (last.type === "text") { + return "Gathering thoughts" + } + return undefined + }) - const timeSinceLastChange = Date.now() - store.lastStatusChange + function duration() { + const completed = lastAssistantMessage()?.time.completed + const from = DateTime.fromMillis(message()!.time.created) + const to = completed ? DateTime.fromMillis(completed) : DateTime.now() + const interval = Interval.fromDateTimes(from, to) + const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] - if (timeSinceLastChange >= 2500) { - setStore("status", newStatus) - setStore("lastStatusChange", Date.now()) - if (store.statusTimeout) { - clearTimeout(store.statusTimeout) - setStore("statusTimeout", undefined) - } - } else { - if (store.statusTimeout) clearTimeout(store.statusTimeout) - setStore( - "statusTimeout", - setTimeout(() => { - setStore("status", rawStatus()) - setStore("lastStatusChange", Date.now()) - setStore("statusTimeout", undefined) - }, 2500 - timeSinceLastChange) as unknown as number, - ) - } - }) + return interval.toDuration(unit).normalize().toHuman({ + notation: "compact", + unitDisplay: "narrow", + compactDisplay: "short", + showZeros: false, + }) + } - createEffect((prev) => { - const isWorking = working() - if (prev && !isWorking && !store.userScrolled) { - setStore("stepsExpanded", false) - } - return isWorking - }, working()) + createEffect(() => { + lastPart() + scrollToBottom() + }) - return ( -
-
-
-
- {/* Title (sticky) */} -
setStore("stickyTitleRef", el)} data-slot="session-turn-sticky-title"> -
-
- - - - - -

{message()!.summary?.title}

-
-
-
-
-
- {/* User Message */} -
- -
- {/* Trigger (sticky) */} -
setStore("stickyTriggerRef", el)} data-slot="session-turn-response-trigger"> - -
- {/* Response */} - -
- - {(assistantMessage) => { - const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) - const last = createMemo(() => - parts() - .filter((p) => p?.type === "text") - .at(-1), - ) - return ( + const [store, setStore] = createStore({ + status: rawStatus(), + stepsExpanded: true, + duration: duration(), + }) + + createEffect(() => { + const timer = setInterval(() => { + setStore("duration", duration()) + }, 1000) + onCleanup(() => clearInterval(timer)) + }) + + let lastStatusChange = Date.now() + let statusTimeout: number | undefined + createEffect(() => { + const newStatus = rawStatus() + if (newStatus === store.status || !newStatus) return + + const timeSinceLastChange = Date.now() - lastStatusChange + + if (timeSinceLastChange >= 2500) { + setStore("status", newStatus) + lastStatusChange = Date.now() + if (statusTimeout) { + clearTimeout(statusTimeout) + statusTimeout = undefined + } + } else { + if (statusTimeout) clearTimeout(statusTimeout) + statusTimeout = setTimeout(() => { + setStore("status", rawStatus()) + lastStatusChange = Date.now() + statusTimeout = undefined + }, 2500 - timeSinceLastChange) as unknown as number + } + }) + + createEffect((prev) => { + const isWorking = working() + if (prev && !isWorking && !state.userScrolled) { + setStore("stepsExpanded", false) + } + return isWorking + }, working()) + + return ( +
+ {/* Title (sticky) */} +
setState("stickyTitleRef", el)} data-slot="session-turn-sticky-title"> +
+
+ + + + + +

{message().summary?.title}

+
+
+
+
+
+ {/* User Message */} +
+ +
+ {/* Trigger (sticky) */} +
setState("stickyTriggerRef", el)} data-slot="session-turn-response-trigger"> +
- - {/* Summary */} - -
-
-

- - Summary - Response - -

- - {(summary) => ( - - )} + · + {store.duration} + + +
+ {/* Response */} + +
+ + {(assistantMessage) => { + const parts = createMemo(() => data.store.part[assistantMessage.id] ?? []) + const last = createMemo(() => + parts() + .filter((p) => p?.type === "text") + .at(-1), + ) + return ( + + + p?.id !== last()?.id)} + /> + + + + + + ) + }} + + + + {error()?.data?.message as string} + + +
+
+ {/* Summary */} + +
+
+

+ + Summary + Response + +

+ + {(summary) => ( + + )} + +
+ + + {(diff) => ( + + + +
+
+ +
+ + {getDirectory(diff.file)}‎ + + {getFilename(diff.file)} +
+
+
+ + +
+
+
+
+ + + +
+ )} +
+
+
+
+ + + {error()?.data?.message as string} +
- - - {(diff) => ( - - - -
-
- -
- - {getDirectory(diff.file)}‎ - - {getFilename(diff.file)} -
-
-
- - -
-
-
-
- - - -
- )} -
-
-
- - - - {error()?.data?.message as string} - - -
+ ) + }} +
{props.children}
-- cgit v1.2.3 From ae8c4154aa51f8a89ac90a04e2b1460291bb3fef Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 06:18:23 -0600 Subject: wip(desktop): progress --- packages/ui/src/components/session-turn.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/ui/src/components') diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index ad2e6c36e..54dd01091 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -222,7 +222,7 @@ export function SessionTurn( const [store, setStore] = createStore({ status: rawStatus(), - stepsExpanded: true, + stepsExpanded: working(), duration: duration(), }) -- cgit v1.2.3 From df2ebfac7d3dca6c2262258e6ee85a3c22cc53c3 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 06:52:54 -0600 Subject: wip(desktop): progress --- packages/desktop/src/context/layout.tsx | 15 +++++++ packages/desktop/src/pages/layout.tsx | 6 +++ packages/desktop/src/pages/session.tsx | 69 ++++++++++++++++++++++++++++- packages/ui/src/components/session-turn.tsx | 20 +++++++-- 4 files changed, 106 insertions(+), 4 deletions(-) (limited to 'packages/ui/src/components') diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index af71c6a00..604f7c5d1 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -46,6 +46,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( review: { state: "pane" as "pane" | "tab", }, + steps: { + expanded: false, + }, sessionTabs: {} as Record, }), { @@ -161,6 +164,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("review", "state", "tab") }, }, + steps: { + expanded: createMemo(() => store.steps?.expanded ?? false), + toggle() { + setStore("steps", "expanded", (x) => !x) + }, + expand() { + setStore("steps", "expanded", true) + }, + collapse() { + setStore("steps", "expanded", false) + }, + }, tabs(sessionKey: string) { const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] }) return { diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index bb2302503..53078e01b 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -156,6 +156,12 @@ export default function Layout(props: ParentProps) { }, ] : []), + { + id: "provider.connect", + title: "Connect provider", + category: "Provider", + onSelect: () => connectProvider(), + }, { id: "session.previous", title: "Previous session", diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 5c74f2d2e..d0c3bf7de 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -34,6 +34,7 @@ import { Terminal } from "@/components/terminal" import { checksum } from "@opencode-ai/util/encode" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" +import { DialogSelectModel } from "@/components/dialog-select-model" import { useCommand } from "@/context/command" import { useNavigate, useParams } from "@solidjs/router" import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" @@ -70,6 +71,25 @@ export default function Page() { setMessageStore("messageId", message?.id) } + function navigateMessageByOffset(offset: number) { + const messages = userMessages() + if (messages.length === 0) return + + const current = activeMessage() + const currentIndex = current ? messages.findIndex((m) => m.id === current.id) : -1 + + let targetIndex: number + if (currentIndex === -1) { + targetIndex = offset > 0 ? 0 : messages.length - 1 + } else { + targetIndex = currentIndex + offset + } + + if (targetIndex < 0 || targetIndex >= messages.length) return + + setActiveMessage(messages[targetIndex]) + } + const last = createMemo( () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage, ) @@ -118,7 +138,7 @@ export default function Page() { title: "New session", description: "Create a new session", category: "Session", - keybind: "mod+n", + keybind: "mod+shift+s", slash: "new", onSelect: () => navigate(`/${params.dir}/session`), }, @@ -163,6 +183,49 @@ export default function Page() { keybind: "ctrl+shift+`", onSelect: () => terminal.new(), }, + { + id: "steps.toggle", + title: "Toggle steps", + description: "Show or hide the steps", + category: "View", + keybind: "mod+e", + slash: "steps", + onSelect: () => layout.steps.toggle(), + }, + { + id: "message.previous", + title: "Previous message", + description: "Go to the previous user message", + category: "Session", + keybind: "mod+arrowup", + disabled: !params.id, + onSelect: () => navigateMessageByOffset(-1), + }, + { + id: "message.next", + title: "Next message", + description: "Go to the next user message", + category: "Session", + keybind: "mod+arrowdown", + disabled: !params.id, + onSelect: () => navigateMessageByOffset(1), + }, + { + id: "model.choose", + title: "Choose model", + description: "Select a different model", + category: "Model", + slash: "model", + onSelect: () => dialog.replace(() => ), + }, + { + id: "agent.cycle", + title: "Cycle agent", + description: "Switch to the next agent", + category: "Agent", + slash: "agent", + onSelect: () => local.agent.move(1), + }, ]) // Handle keyboard events that aren't commands @@ -492,6 +555,10 @@ export default function Page() { + expanded ? layout.steps.expand() : layout.steps.collapse() + } classes={{ root: "pb-20 flex-1 min-w-0", content: "pb-20", diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 54dd01091..807092d03 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -24,6 +24,8 @@ export function SessionTurn( props: ParentProps<{ sessionID: string messageID: string + stepsExpanded?: boolean + onStepsExpandedChange?: (expanded: boolean) => void classes?: { root?: string content?: string @@ -222,10 +224,17 @@ export function SessionTurn( const [store, setStore] = createStore({ status: rawStatus(), - stepsExpanded: working(), + stepsExpanded: props.stepsExpanded ?? working(), duration: duration(), }) + // Sync with controlled prop + createEffect(() => { + if (props.stepsExpanded !== undefined) { + setStore("stepsExpanded", props.stepsExpanded) + } + }) + createEffect(() => { const timer = setInterval(() => { setStore("duration", duration()) @@ -262,6 +271,7 @@ export function SessionTurn( const isWorking = working() if (prev && !isWorking && !state.userScrolled) { setStore("stepsExpanded", false) + props.onStepsExpandedChange?.(false) } return isWorking }, working()) @@ -278,7 +288,7 @@ export function SessionTurn(
- + @@ -298,7 +308,11 @@ export function SessionTurn( data-slot="session-turn-collapsible-trigger-content" variant="ghost" size="small" - onClick={() => setStore("stepsExpanded", !store.stepsExpanded)} + onClick={() => { + const next = !store.stepsExpanded + setStore("stepsExpanded", next) + props.onStepsExpandedChange?.(next) + }} > -- cgit v1.2.3 From df2713a6c263a006539efad84e64103caee2d3f5 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 07:14:03 -0600 Subject: chore: cleanup --- packages/desktop/src/components/prompt-input.tsx | 21 ++++++--------------- packages/desktop/src/context/command.tsx | 17 ----------------- packages/desktop/src/pages/session.tsx | 19 +++---------------- packages/ui/src/components/session-turn.tsx | 1 - 4 files changed, 9 insertions(+), 49 deletions(-) (limited to 'packages/ui/src/components') diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 87f91104c..9be09507a 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -77,7 +77,6 @@ export const PromptInput: Component = (props) => { const command = useCommand() let editorRef!: HTMLDivElement - // Session-derived state const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) @@ -183,10 +182,10 @@ export const PromptInput: Component = (props) => { } onMount(() => { - editorRef?.addEventListener("paste", handlePaste) + editorRef.addEventListener("paste", handlePaste) }) onCleanup(() => { - editorRef?.removeEventListener("paste", handlePaste) + editorRef.removeEventListener("paste", handlePaste) }) createEffect(() => { @@ -208,7 +207,6 @@ export const PromptInput: Component = (props) => { onSelect: handleFileSelect, }) - // Get slash commands from registered commands (only those with explicit slash trigger) const slashCommands = createMemo(() => { const builtin = command.options .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) @@ -237,12 +235,10 @@ export const PromptInput: Component = (props) => { setStore("popover", null) if (cmd.type === "custom") { - // For custom commands, insert the command text so user can add arguments const text = `/${cmd.trigger} ` editorRef.innerHTML = "" editorRef.textContent = text prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) - // Set cursor at end requestAnimationFrame(() => { editorRef.focus() const range = document.createRange() @@ -255,7 +251,6 @@ export const PromptInput: Component = (props) => { return } - // For built-in commands, clear input and execute immediately editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) command.trigger(cmd.id, "slash") @@ -287,7 +282,7 @@ export const PromptInput: Component = (props) => { } editorRef.innerHTML = "" - currentParts.forEach((part: ContentPart) => { + currentParts.forEach((part) => { if (part.type === "text") { editorRef.appendChild(document.createTextNode(part.content)) } else if (part.type === "file") { @@ -374,7 +369,7 @@ export const PromptInput: Component = (props) => { const cursorPosition = getCursorPosition(editorRef) const currentPrompt = prompt.current() - const rawText = currentPrompt.map((p: ContentPart) => p.content).join("") + const rawText = currentPrompt.map((p) => p.content).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) @@ -498,7 +493,6 @@ export const PromptInput: Component = (props) => { } const handleKeyDown = (event: KeyboardEvent) => { - // Handle popover navigation if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { if (store.popover === "file") { onKeyDown(event) @@ -510,7 +504,6 @@ export const PromptInput: Component = (props) => { } if (event.key === "ArrowUp" || event.key === "ArrowDown") { - // Skip history navigation when modifier keys are pressed (used for other commands) if (event.altKey || event.ctrlKey || event.metaKey) return const { collapsed, onFirstLine, onLastLine } = getCaretLineState() if (!collapsed) return @@ -554,7 +547,7 @@ export const PromptInput: Component = (props) => { const handleSubmit = async (event: Event) => { event.preventDefault() const currentPrompt = prompt.current() - const text = currentPrompt.map((part: ContentPart) => part.content).join("") + const text = currentPrompt.map((part) => part.content).join("") if (text.trim().length === 0) { if (working()) abort() return @@ -574,7 +567,7 @@ export const PromptInput: Component = (props) => { const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) const attachments = currentPrompt.filter( - (part: ContentPart) => part.type === "file", + (part) => part.type === "file", ) as import("@/context/prompt").FileAttachmentPart[] const attachmentParts = attachments.map((attachment) => { @@ -603,7 +596,6 @@ export const PromptInput: Component = (props) => { editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) - // Check if this is a custom command if (text.startsWith("/")) { const [cmdName, ...args] = text.split(" ") const commandName = cmdName.slice(1) // Remove leading "/" @@ -639,7 +631,6 @@ export const PromptInput: Component = (props) => { return (
- {/* Popover for file mentions and slash commands */}
void } @@ -197,7 +182,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const handleKeyDown = (event: KeyboardEvent) => { if (suspended()) return - // Check for command palette keybind (mod+shift+p) const paletteKeybinds = parseKeybind("mod+shift+p") if (matchKeybind(paletteKeybinds, event)) { event.preventDefault() @@ -205,7 +189,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex return } - // Check registered command keybinds for (const option of options()) { if (option.disabled) continue if (!option.keybind) continue diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index d49779587..9e743e48f 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -49,7 +49,6 @@ export default function Page() { const params = useParams() const navigate = useNavigate() - // Session-specific derived state const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) @@ -132,7 +131,6 @@ export default function Page() { } }) - // Register commands for this page command.register(() => [ { id: "session.new", @@ -230,28 +228,17 @@ export default function Page() { }, ]) - // Handle keyboard events that aren't commands const handleKeyDown = (event: KeyboardEvent) => { - // Don't interfere with terminal // @ts-expect-error - if (document.activeElement?.dataset?.component === "terminal") { - return - } - - // Don't interfere with dialogs - if (dialog.stack.length > 0) { - return - } + if (document.activeElement?.dataset?.component === "terminal") return + if (dialog.stack.length > 0) return const focused = document.activeElement === inputRef if (focused) { - if (event.key === "Escape") { - inputRef?.blur() - } + if (event.key === "Escape") inputRef?.blur() return } - // Focus input when typing characters if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { inputRef?.focus() } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 807092d03..f905abbd1 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -228,7 +228,6 @@ export function SessionTurn( duration: duration(), }) - // Sync with controlled prop createEffect(() => { if (props.stepsExpanded !== undefined) { setStore("stepsExpanded", props.stepsExpanded) -- cgit v1.2.3 From 5cf6a1343c6ca088bd2b586197faf7fe58961290 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 15 Dec 2025 09:34:00 -0600 Subject: wip(desktop): progress --- packages/desktop/src/components/prompt-input.tsx | 191 +++++++++++++++++--- packages/desktop/src/context/global-sync.tsx | 40 ++++- packages/desktop/src/context/local.tsx | 2 +- packages/desktop/src/context/prompt.tsx | 14 +- packages/desktop/src/pages/layout.tsx | 211 +++++++++++++++-------- packages/desktop/src/pages/session.tsx | 148 +++++++++++----- packages/desktop/src/utils/prompt.ts | 47 +++++ packages/ui/src/components/message-part.css | 76 +++++++- packages/ui/src/components/message-part.tsx | 96 ++++++++++- packages/ui/src/components/session-turn.tsx | 10 +- 10 files changed, 676 insertions(+), 159 deletions(-) create mode 100644 packages/desktop/src/utils/prompt.ts (limited to 'packages/ui/src/components') diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 37d05c311..f3f758102 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,10 +1,10 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js" -import { createStore } from "solid-js/store" +import { createStore, produce } from "solid-js/store" import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" -import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt } from "@/context/prompt" +import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" import { useNavigate, useParams } from "@solidjs/router" @@ -22,6 +22,9 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid import { useProviders } from "@/hooks/use-providers" import { useCommand, formatKeybind } from "@/context/command" +const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] +const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] + interface PromptInputProps { class?: string ref?: (el: HTMLDivElement) => void @@ -93,11 +96,15 @@ export const PromptInput: Component = (props) => { historyIndex: number savedPrompt: Prompt | null placeholder: number + dragging: boolean + imageAttachments: ImageAttachmentPart[] }>({ popover: null, historyIndex: -1, savedPrompt: null, placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), + dragging: false, + imageAttachments: [], }) const MAX_HISTORY = 100 @@ -113,16 +120,17 @@ export const PromptInput: Component = (props) => { ) const clonePromptParts = (prompt: Prompt): Prompt => - prompt.map((part) => - part.type === "text" - ? { ...part } - : { - ...part, - selection: part.selection ? { ...part.selection } : undefined, - }, - ) + prompt.map((part) => { + if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } + return { + ...part, + selection: part.selection ? { ...part.selection } : undefined, + } + }) - const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0) + const promptLength = (prompt: Prompt) => + prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0) const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const length = position === "start" ? 0 : promptLength(p) @@ -162,14 +170,89 @@ export const PromptInput: Component = (props) => { const isFocused = createFocusSignal(() => editorRef) - const handlePaste = (event: ClipboardEvent) => { + const addImageAttachment = async (file: File) => { + if (!ACCEPTED_FILE_TYPES.includes(file.type)) return + + const reader = new FileReader() + reader.onload = () => { + const dataUrl = reader.result as string + const attachment: ImageAttachmentPart = { + type: "image", + id: crypto.randomUUID(), + filename: file.name, + mime: file.type, + dataUrl, + } + setStore( + produce((draft) => { + draft.imageAttachments.push(attachment) + }), + ) + } + reader.readAsDataURL(file) + } + + const removeImageAttachment = (id: string) => { + setStore( + produce((draft) => { + draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id) + }), + ) + } + + const handlePaste = async (event: ClipboardEvent) => { + const clipboardData = event.clipboardData + if (!clipboardData) return + + const items = Array.from(clipboardData.items) + const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) + + if (imageItems.length > 0) { + event.preventDefault() + event.stopPropagation() + for (const item of imageItems) { + const file = item.getAsFile() + if (file) await addImageAttachment(file) + } + return + } + event.preventDefault() event.stopPropagation() - // @ts-expect-error - const plainText = (event.clipboardData || window.clipboardData)?.getData("text/plain") ?? "" + const plainText = clipboardData.getData("text/plain") ?? "" addPart({ type: "text", content: plainText, start: 0, end: 0 }) } + const handleDragOver = (event: DragEvent) => { + event.preventDefault() + const hasFiles = event.dataTransfer?.types.includes("Files") + if (hasFiles) { + setStore("dragging", true) + } + } + + const handleDragLeave = (event: DragEvent) => { + const related = event.relatedTarget as Node | null + const form = event.currentTarget as HTMLElement + if (!related || !form.contains(related)) { + setStore("dragging", false) + } + } + + const handleDrop = async (event: DragEvent) => { + event.preventDefault() + setStore("dragging", false) + + const files = event.dataTransfer?.files + if (!files) return + + for (const file of Array.from(files)) { + if (ACCEPTED_FILE_TYPES.includes(file.type)) { + await addImageAttachment(file) + } + } + } + onMount(() => { editorRef.addEventListener("paste", handlePaste) }) @@ -328,7 +411,7 @@ export const PromptInput: Component = (props) => { const handleInput = () => { const rawParts = parseFromDOM() const cursorPosition = getCursorPosition(editorRef) - const rawText = rawParts.map((p) => p.content).join("") + const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("") const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) // Slash commands only trigger when / is at the start of input @@ -358,7 +441,7 @@ export const PromptInput: Component = (props) => { const cursorPosition = getCursorPosition(editorRef) const currentPrompt = prompt.current() - const rawText = currentPrompt.map((p) => p.content).join("") + const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) @@ -424,7 +507,7 @@ export const PromptInput: Component = (props) => { const addToHistory = (prompt: Prompt) => { const text = prompt - .map((p) => p.content) + .map((p) => ("content" in p ? p.content : "")) .join("") .trim() if (!text) return @@ -432,7 +515,7 @@ export const PromptInput: Component = (props) => { const entry = clonePromptParts(prompt) const lastEntry = history.entries[0] if (lastEntry) { - const lastText = lastEntry.map((p) => p.content).join("") + const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("") if (lastText === text) return } @@ -532,8 +615,9 @@ export const PromptInput: Component = (props) => { const handleSubmit = async (event: Event) => { event.preventDefault() const currentPrompt = prompt.current() - const text = currentPrompt.map((part) => part.content).join("") - if (text.trim().length === 0) { + const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") + const hasImageAttachments = store.imageAttachments.length > 0 + if (text.trim().length === 0 && !hasImageAttachments) { if (working()) abort() return } @@ -555,7 +639,7 @@ export const PromptInput: Component = (props) => { (part) => part.type === "file", ) as import("@/context/prompt").FileAttachmentPart[] - const attachmentParts = attachments.map((attachment) => { + const fileAttachmentParts = attachments.map((attachment) => { const absolute = toAbsolutePath(attachment.path) const query = attachment.selection ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` @@ -577,9 +661,17 @@ export const PromptInput: Component = (props) => { } }) + const imageAttachmentParts = store.imageAttachments.map((attachment) => ({ + type: "file" as const, + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + })) + tabs().setActive(undefined) editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + setStore("imageAttachments", []) if (text.startsWith("/")) { const [cmdName, ...args] = text.split(" ") @@ -609,7 +701,8 @@ export const PromptInput: Component = (props) => { type: "text", text, }, - ...attachmentParts, + ...fileAttachmentParts, + ...imageAttachmentParts, ], }) } @@ -686,12 +779,58 @@ export const PromptInput: Component = (props) => {
+ +
+
+ + Drop images or PDFs here +
+
+
+ 0}> +
+ + {(attachment) => ( +
+ + +
+ } + > + {attachment.filename} + + +
+ {attachment.filename} +
+
+ )} + +
+
{ @@ -706,7 +845,7 @@ export const PromptInput: Component = (props) => { "[&>[data-type=file]]:text-icon-info-active": true, }} /> - +
Ask anything... "{PLACEHOLDERS[store.placeholder]}"
@@ -735,7 +874,7 @@ export const PromptInput: Component = (props) => {
@@ -755,7 +894,7 @@ export const PromptInput: Component = (props) => { > { - const sessions = (x.data ?? []) + const oneHourAgo = Date.now() - 60 * 60 * 1000 + const nonArchived = (x.data ?? []) .slice() .filter((s) => !s.time.archived) .sort((a, b) => a.id.localeCompare(b.id)) - .slice(0, 5) + // Include at least 5 sessions, plus any updated in the last hour + const sessions = nonArchived.filter((s, i) => { + if (i < 5) return true + const updated = new Date(s.time.updated).getTime() + return updated > oneHourAgo + }) const [, setStore] = child(directory) setStore("session", sessions) }) @@ -220,6 +226,21 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple ) break } + case "message.removed": { + const messages = store.message[event.properties.sessionID] + if (!messages) break + const result = Binary.search(messages, event.properties.messageID, (m) => m.id) + if (result.found) { + setStore( + "message", + event.properties.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + break + } case "message.part.updated": { const part = event.properties.part const parts = store.part[part.messageID] @@ -241,6 +262,21 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple ) break } + case "message.part.removed": { + const parts = store.part[event.properties.messageID] + if (!parts) break + const result = Binary.search(parts, event.properties.partID, (p) => p.id) + if (result.found) { + setStore( + "part", + event.properties.messageID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + break + } } }) diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 6ec9778cc..b12679210 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -406,7 +406,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ case "file.watcher.updated": const relativePath = relative(event.properties.file) if (relativePath.startsWith(".git/")) return - load(relativePath) + if (store.node[relativePath]) load(relativePath) break } }) diff --git a/packages/desktop/src/context/prompt.tsx b/packages/desktop/src/context/prompt.tsx index c3b3bbace..2da0a08d5 100644 --- a/packages/desktop/src/context/prompt.tsx +++ b/packages/desktop/src/context/prompt.tsx @@ -21,7 +21,15 @@ export interface FileAttachmentPart extends PartBase { selection?: TextSelection } -export type ContentPart = TextPart | FileAttachmentPart +export interface ImageAttachmentPart { + type: "image" + id: string + filename: string + mime: string + dataUrl: string +} + +export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart export type Prompt = ContentPart[] export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] @@ -38,6 +46,9 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { return false } + if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) { + return false + } } return true } @@ -49,6 +60,7 @@ function cloneSelection(selection?: TextSelection) { function clonePart(part: ContentPart): ContentPart { if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } return { ...part, selection: cloneSelection(part.selection), diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 53078e01b..6632abe3a 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -55,10 +55,32 @@ export default function Layout(props: ParentProps) { const dialog = useDialog() const command = useCommand() + function flattenSessions(sessions: Session[]): Session[] { + const childrenMap = new Map() + for (const session of sessions) { + if (session.parentID) { + const children = childrenMap.get(session.parentID) ?? [] + children.push(session) + childrenMap.set(session.parentID, children) + } + } + const result: Session[] = [] + function visit(session: Session) { + result.push(session) + for (const child of childrenMap.get(session.id) ?? []) { + visit(child) + } + } + for (const session of sessions) { + if (!session.parentID) visit(session) + } + return result + } + const currentSessions = createMemo(() => { if (!params.dir) return [] const directory = base64Decode(params.dir) - return globalSync.child(directory)[0].session ?? [] + return flattenSessions(globalSync.child(directory)[0].session ?? []) }) function navigateSessionByOffset(offset: number) { @@ -98,7 +120,7 @@ export default function Layout(props: ParentProps) { const nextProject = projects[nextProjectIndex] if (!nextProject) return - const nextProjectSessions = globalSync.child(nextProject.worktree)[0].session ?? [] + const nextProjectSessions = flattenSessions(globalSync.child(nextProject.worktree)[0].session ?? []) if (nextProjectSessions.length === 0) { // Navigate to the project's new session page if no sessions navigateToProject(nextProject.worktree) @@ -375,6 +397,98 @@ export default function Layout(props: ParentProps) { ) } + const SessionItem = (props: { + session: Session + slug: string + project: Project + depth?: number + childrenMap: Map + }): JSX.Element => { + const notification = useNotification() + const depth = props.depth ?? 0 + const children = createMemo(() => props.childrenMap.get(props.session.id) ?? []) + const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) + const notifications = createMemo(() => notification.session.unseen(props.session.id)) + const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const isWorking = createMemo( + () => + props.session.id !== params.id && + globalSync.child(props.project.worktree)[0].session_status[props.session.id]?.type === "busy", + ) + return ( + <> +
+ + +
+ + {props.session.title} + + + + {(child) => ( + + )} + + + ) + } + const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => { const notification = useNotification() const sortable = createSortable(props.project.worktree) @@ -382,6 +496,18 @@ export default function Layout(props: ParentProps) { const name = createMemo(() => getFilename(props.project.worktree)) const [store, setStore] = globalSync.child(props.project.worktree) const sessions = createMemo(() => store.session ?? []) + const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) + const childSessionsByParent = createMemo(() => { + const map = new Map() + for (const session of sessions()) { + if (session.parentID) { + const children = map.get(session.parentID) ?? [] + children.push(session) + map.set(session.parentID, children) + } + } + return map + }) const [expanded, setExpanded] = createSignal(true) return ( // @ts-ignore @@ -421,78 +547,17 @@ export default function Layout(props: ParentProps) {