From 4279b25ff40aa01a89c4cd38c21df5d2337ba587 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Mon, 9 Feb 2026 12:02:15 +0000 Subject: [PATCH 01/35] feat(ui): hydrate session diffs on open Fetch session-level diffs when a session is opened and keep them updated via session.diff SSE events so UI state stays in sync with server changes. --- packages/ui/src/lib/sse-manager.ts | 7 ++++ packages/ui/src/stores/session-api.ts | 50 ++++++++++++++++++++++++ packages/ui/src/stores/session-events.ts | 27 +++++++++++++ packages/ui/src/stores/sessions.ts | 2 + packages/ui/src/types/session.ts | 2 + 5 files changed, 88 insertions(+) diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index 98d32d81..d777ef38 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -9,6 +9,7 @@ import type { EventLspUpdated, EventSessionCompacted, + EventSessionDiff, EventSessionError, EventSessionIdle, EventSessionUpdated, @@ -59,8 +60,10 @@ type SSEEvent = | MessagePartRemovedEvent | EventSessionUpdated | EventSessionCompacted + | EventSessionDiff | EventSessionError | EventSessionIdle + | EventSessionStatus | { type: "permission.updated" | "permission.asked"; properties?: any } | { type: "permission.replied"; properties?: any } | { type: "question.asked"; properties?: any } @@ -139,6 +142,9 @@ class SSEManager { case "session.status": this.onSessionStatus?.(instanceId, event as EventSessionStatus) break + case "session.diff": + this.onSessionDiff?.(instanceId, event as EventSessionDiff) + break case "permission.updated": case "permission.asked": this.onPermissionUpdated?.(instanceId, event as any) @@ -185,6 +191,7 @@ class SSEManager { onTuiToast?: (instanceId: string, event: TuiToastEvent) => void onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void + onSessionDiff?: (instanceId: string, event: EventSessionDiff) => void onPermissionUpdated?: (instanceId: string, event: any) => void onPermissionReplied?: (instanceId: string, event: any) => void onQuestionAsked?: (instanceId: string, event: any) => void diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index fcc653af..dc8ad63e 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -1,5 +1,6 @@ import { mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session" import type { Message } from "../types/message" +import type { FileDiff } from "@opencode-ai/sdk/v2/client" import { instances } from "./instances" import { preferences, setAgentModelPreference } from "./preferences" @@ -19,6 +20,7 @@ import { setSessionInfoByInstance, setSessions, sessions, + withSession, loading, setLoading, cleanupBlankSessions, @@ -42,6 +44,49 @@ import { const log = getLogger("api") +const pendingSessionDiffFetches = new Map>() + +async function loadSessionDiff(instanceId: string, sessionId: string, force = false): Promise { + if (!instanceId || !sessionId) return + + const key = `${instanceId}:${sessionId}` + if (!force) { + const existing = sessions().get(instanceId)?.get(sessionId) + if (existing?.diff !== undefined) return + const pending = pendingSessionDiffFetches.get(key) + if (pending) return pending + } + + const promise = (async () => { + const instance = instances().get(instanceId) + if (!instance?.client) return + + const worktreeSlug = getWorktreeSlugForSession(instanceId, sessionId) + const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + + try { + const diffs = await requestData( + client.session.diff({ sessionID: sessionId }), + "session.diff", + ) + + if (!Array.isArray(diffs)) { + return + } + + withSession(instanceId, sessionId, (session) => { + session.diff = diffs + }) + } catch (error) { + log.warn("Failed to fetch session diff", { instanceId, sessionId, error }) + } + })() + + pendingSessionDiffFetches.set(key, promise) + void promise.finally(() => pendingSessionDiffFetches.delete(key)) + return promise +} + interface SessionForkResponse { id: string title?: string @@ -570,6 +615,11 @@ async function loadMessages(instanceId: string, sessionId: string, force = false throw new Error("Session not found") } + // Fetch session-level diffs in the background once the session is opened. + void loadSessionDiff(instanceId, sessionId).catch((error) => { + log.warn("Failed to load session diff", { instanceId, sessionId, error }) + }) + setLoading((prev) => { const next = { ...prev } const loadingSet = next.loadingMessages.get(instanceId) || new Set() diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 44a0ecbe..f5516a8a 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -7,6 +7,7 @@ import type { } from "../types/message" import type { EventSessionCompacted, + EventSessionDiff, EventSessionError, EventSessionIdle, EventSessionUpdated, @@ -428,6 +429,31 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo } } +function handleSessionDiff(instanceId: string, event: EventSessionDiff): void { + const sessionId = event.properties?.sessionID + if (!sessionId) return + + const diffs = event.properties?.diff + if (!Array.isArray(diffs)) return + + const existing = sessions().get(instanceId)?.get(sessionId) + if (existing) { + withSession(instanceId, sessionId, (session) => { + session.diff = diffs + }) + return + } + + // A diff event can arrive before we have hydrated the session list. + // Best-effort: fetch the session record so the diff has somewhere to live. + void (async () => { + await fetchSessionInfo(instanceId, sessionId, (event as any)?.directory) + withSession(instanceId, sessionId, (session) => { + session.diff = diffs + }) + })().catch((error) => log.warn("Failed to hydrate session for diff event", { instanceId, sessionId, error })) +} + function handleSessionIdle(instanceId: string, event: EventSessionIdle): void { const sessionId = event.properties?.sessionID if (!sessionId) return @@ -605,6 +631,7 @@ export { handleQuestionAsked, handleQuestionAnswered, handleSessionCompacted, + handleSessionDiff, handleSessionError, handleSessionIdle, handleSessionStatus, diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index 48f5298c..ef056a0e 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -64,6 +64,7 @@ import { handleQuestionAnswered, handleQuestionAsked, handleSessionCompacted, + handleSessionDiff, handleSessionError, handleSessionIdle, handleSessionStatus, @@ -77,6 +78,7 @@ sseManager.onMessageRemoved = handleMessageRemoved sseManager.onMessagePartRemoved = handleMessagePartRemoved sseManager.onSessionUpdate = handleSessionUpdate sseManager.onSessionCompacted = handleSessionCompacted +sseManager.onSessionDiff = handleSessionDiff sseManager.onSessionError = handleSessionError sseManager.onSessionIdle = handleSessionIdle sseManager.onSessionStatus = handleSessionStatus diff --git a/packages/ui/src/types/session.ts b/packages/ui/src/types/session.ts index e26f0a06..1cae7c21 100644 --- a/packages/ui/src/types/session.ts +++ b/packages/ui/src/types/session.ts @@ -5,6 +5,7 @@ import type { Model as SDKModel, } from "@opencode-ai/sdk" import type { SessionStatus as SDKSessionStatus } from "@opencode-ai/sdk/v2/client" +import type { FileDiff } from "@opencode-ai/sdk/v2/client" // Export SDK types for external use export type { @@ -39,6 +40,7 @@ export interface Session pendingPermission?: boolean // Indicates if session is waiting on user permission pendingQuestion?: boolean // Indicates if session is waiting on user input status: SessionStatus // Single source of truth for session status + diff?: FileDiff[] // Session-level file diffs (hydrated via session.diff) } // Adapter function to convert SDK Session to client Session From d360089b80615e4455fd1dd9b53d360de7b1c5ff Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Mon, 9 Feb 2026 13:03:44 +0000 Subject: [PATCH 02/35] feat(ui): add Session Changes sidebar section Show session-level file changes in the right drawer with per-file +additions/-deletions and a Show changes button that appears only when diffs exist. --- .../components/instance/instance-shell2.tsx | 74 +++++++++++++++++++ .../ui/src/lib/i18n/messages/en/instance.ts | 7 ++ .../ui/src/lib/i18n/messages/es/instance.ts | 7 ++ .../ui/src/lib/i18n/messages/fr/instance.ts | 7 ++ .../ui/src/lib/i18n/messages/ja/instance.ts | 7 ++ .../ui/src/lib/i18n/messages/ru/instance.ts | 7 ++ .../src/lib/i18n/messages/zh-Hans/instance.ts | 7 ++ 7 files changed, 116 insertions(+) diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index f4349229..23005b9d 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -304,6 +304,11 @@ const InstanceShell2: Component = (props) => { return activeSessions().get(sessionId) ?? null }) + const activeSessionDiffs = createMemo(() => { + const session = activeSessionForInstance() + return session?.diff + }) + const activeSessionUsage = createMemo(() => { const sessionId = activeSessionIdForInstance() if (!sessionId) return null @@ -960,6 +965,70 @@ const InstanceShell2: Component = (props) => { ) const RightDrawerContent = () => { + const renderSessionChanges = () => { + const sessionId = activeSessionIdForInstance() + if (!sessionId || sessionId === "info") { + return

{t("instanceShell.sessionChanges.noSessionSelected")}

+ } + + const diffs = activeSessionDiffs() + if (diffs === undefined) { + return

{t("instanceShell.sessionChanges.loading")}

+ } + + if (!Array.isArray(diffs) || diffs.length === 0) { + return

{t("instanceShell.sessionChanges.empty")}

+ } + + const sorted = [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) + const totals = sorted.reduce( + (acc, item) => { + acc.additions += typeof item.additions === "number" ? item.additions : 0 + acc.deletions += typeof item.deletions === "number" ? item.deletions : 0 + return acc + }, + { additions: 0, deletions: 0 }, + ) + + return ( +
+
+ {t("instanceShell.sessionChanges.filesChanged", { count: sorted.length })} + + {`+${totals.additions}`} + {`-${totals.deletions}`} + +
+ +
+
+ + {(item) => ( +
+
+ {item.file} +
+
+ {`+${item.additions}`} + {`-${item.deletions}`} +
+
+ )} +
+
+
+ + +
+ ) + } + const renderPlanSectionContent = () => { const sessionId = activeSessionIdForInstance() if (!sessionId || sessionId === "info") { @@ -1034,6 +1103,11 @@ const InstanceShell2: Component = (props) => { } const sections = [ + { + id: "session-changes", + labelKey: "instanceShell.rightPanel.sections.sessionChanges", + render: renderSessionChanges, + }, { id: "plan", labelKey: "instanceShell.rightPanel.sections.plan", diff --git a/packages/ui/src/lib/i18n/messages/en/instance.ts b/packages/ui/src/lib/i18n/messages/en/instance.ts index d45f2c56..feb970f7 100644 --- a/packages/ui/src/lib/i18n/messages/en/instance.ts +++ b/packages/ui/src/lib/i18n/messages/en/instance.ts @@ -86,12 +86,19 @@ export const instanceMessages = { "instanceShell.empty.description": "Select a session to view messages", "instanceShell.rightPanel.title": "Status Panel", + "instanceShell.rightPanel.sections.sessionChanges": "Session Changes", "instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.backgroundProcesses": "Background Shells", "instanceShell.rightPanel.sections.mcp": "MCP Servers", "instanceShell.rightPanel.sections.lsp": "LSP Servers", "instanceShell.rightPanel.sections.plugins": "Plugins", + "instanceShell.sessionChanges.noSessionSelected": "Select a session to view changes.", + "instanceShell.sessionChanges.loading": "Fetching session changes...", + "instanceShell.sessionChanges.empty": "No session changes yet.", + "instanceShell.sessionChanges.filesChanged": "{count} files changed", + "instanceShell.sessionChanges.actions.show": "Show changes", + "instanceShell.plan.noSessionSelected": "Select a session to view plan.", "instanceShell.plan.empty": "Nothing planned yet.", diff --git a/packages/ui/src/lib/i18n/messages/es/instance.ts b/packages/ui/src/lib/i18n/messages/es/instance.ts index a2252e6b..e48bcd23 100644 --- a/packages/ui/src/lib/i18n/messages/es/instance.ts +++ b/packages/ui/src/lib/i18n/messages/es/instance.ts @@ -86,12 +86,19 @@ export const instanceMessages = { "instanceShell.empty.description": "Selecciona una sesión para ver mensajes", "instanceShell.rightPanel.title": "Panel de estado", + "instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesion", "instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.backgroundProcesses": "Shells en segundo plano", "instanceShell.rightPanel.sections.mcp": "Servidores MCP", "instanceShell.rightPanel.sections.lsp": "Servidores LSP", "instanceShell.rightPanel.sections.plugins": "Plugins", + "instanceShell.sessionChanges.noSessionSelected": "Selecciona una sesion para ver los cambios.", + "instanceShell.sessionChanges.loading": "Obteniendo cambios de la sesion...", + "instanceShell.sessionChanges.empty": "Aun no hay cambios.", + "instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados", + "instanceShell.sessionChanges.actions.show": "Mostrar cambios", + "instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.", "instanceShell.plan.empty": "Aún no hay nada planificado.", diff --git a/packages/ui/src/lib/i18n/messages/fr/instance.ts b/packages/ui/src/lib/i18n/messages/fr/instance.ts index 562f50ef..469f00ef 100644 --- a/packages/ui/src/lib/i18n/messages/fr/instance.ts +++ b/packages/ui/src/lib/i18n/messages/fr/instance.ts @@ -86,12 +86,19 @@ export const instanceMessages = { "instanceShell.empty.description": "Sélectionnez une session pour voir les messages", "instanceShell.rightPanel.title": "Panneau d'état", + "instanceShell.rightPanel.sections.sessionChanges": "Changements de session", "instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.backgroundProcesses": "Shells en arrière-plan", "instanceShell.rightPanel.sections.mcp": "Serveurs MCP", "instanceShell.rightPanel.sections.lsp": "Serveurs LSP", "instanceShell.rightPanel.sections.plugins": "Plugins", + "instanceShell.sessionChanges.noSessionSelected": "Sélectionnez une session pour voir les changements.", + "instanceShell.sessionChanges.loading": "Récupération des changements...", + "instanceShell.sessionChanges.empty": "Aucun changement pour l'instant.", + "instanceShell.sessionChanges.filesChanged": "{count} fichiers modifiés", + "instanceShell.sessionChanges.actions.show": "Afficher les changements", + "instanceShell.plan.noSessionSelected": "Sélectionnez une session pour voir le plan.", "instanceShell.plan.empty": "Aucun plan pour l'instant.", diff --git a/packages/ui/src/lib/i18n/messages/ja/instance.ts b/packages/ui/src/lib/i18n/messages/ja/instance.ts index aca0ec86..657ddd7e 100644 --- a/packages/ui/src/lib/i18n/messages/ja/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ja/instance.ts @@ -86,12 +86,19 @@ export const instanceMessages = { "instanceShell.empty.description": "メッセージを表示するにはセッションを選択してください", "instanceShell.rightPanel.title": "ステータスパネル", + "instanceShell.rightPanel.sections.sessionChanges": "セッション変更", "instanceShell.rightPanel.sections.plan": "計画", "instanceShell.rightPanel.sections.backgroundProcesses": "バックグラウンドシェル", "instanceShell.rightPanel.sections.mcp": "MCP サーバー", "instanceShell.rightPanel.sections.lsp": "LSP サーバー", "instanceShell.rightPanel.sections.plugins": "プラグイン", + "instanceShell.sessionChanges.noSessionSelected": "変更を表示するにはセッションを選択してください。", + "instanceShell.sessionChanges.loading": "変更を取得中...", + "instanceShell.sessionChanges.empty": "まだ変更はありません。", + "instanceShell.sessionChanges.filesChanged": "{count} 個のファイルが変更されました", + "instanceShell.sessionChanges.actions.show": "変更を表示", + "instanceShell.plan.noSessionSelected": "計画を表示するにはセッションを選択してください。", "instanceShell.plan.empty": "まだ計画はありません。", diff --git a/packages/ui/src/lib/i18n/messages/ru/instance.ts b/packages/ui/src/lib/i18n/messages/ru/instance.ts index b6d536b0..054459e5 100644 --- a/packages/ui/src/lib/i18n/messages/ru/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ru/instance.ts @@ -86,12 +86,19 @@ export const instanceMessages = { "instanceShell.empty.description": "Выберите сессию, чтобы просмотреть сообщения", "instanceShell.rightPanel.title": "Панель состояния", + "instanceShell.rightPanel.sections.sessionChanges": "Изменения сессии", "instanceShell.rightPanel.sections.plan": "План", "instanceShell.rightPanel.sections.backgroundProcesses": "Фоновые Shell", "instanceShell.rightPanel.sections.mcp": "MCP-серверы", "instanceShell.rightPanel.sections.lsp": "LSP-серверы", "instanceShell.rightPanel.sections.plugins": "Плагины", + "instanceShell.sessionChanges.noSessionSelected": "Выберите сессию, чтобы просмотреть изменения.", + "instanceShell.sessionChanges.loading": "Загрузка изменений...", + "instanceShell.sessionChanges.empty": "Пока нет изменений.", + "instanceShell.sessionChanges.filesChanged": "Изменено файлов: {count}", + "instanceShell.sessionChanges.actions.show": "Показать изменения", + "instanceShell.plan.noSessionSelected": "Выберите сессию, чтобы просмотреть план.", "instanceShell.plan.empty": "Пока ничего не запланировано.", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts index 247b474b..b7829724 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts @@ -86,12 +86,19 @@ export const instanceMessages = { "instanceShell.empty.description": "选择会话以查看消息", "instanceShell.rightPanel.title": "状态面板", + "instanceShell.rightPanel.sections.sessionChanges": "会话更改", "instanceShell.rightPanel.sections.plan": "计划", "instanceShell.rightPanel.sections.backgroundProcesses": "后台 Shell", "instanceShell.rightPanel.sections.mcp": "MCP 服务器", "instanceShell.rightPanel.sections.lsp": "LSP 服务器", "instanceShell.rightPanel.sections.plugins": "插件", + "instanceShell.sessionChanges.noSessionSelected": "选择会话以查看更改。", + "instanceShell.sessionChanges.loading": "正在获取会话更改...", + "instanceShell.sessionChanges.empty": "暂无会话更改。", + "instanceShell.sessionChanges.filesChanged": "已更改 {count} 个文件", + "instanceShell.sessionChanges.actions.show": "显示更改", + "instanceShell.plan.noSessionSelected": "选择会话以查看计划。", "instanceShell.plan.empty": "暂无计划。", From 8c297418308057ccebf6eeafa950150925886101 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Mon, 9 Feb 2026 13:08:42 +0000 Subject: [PATCH 03/35] feat(ui): render session changes list in one line Show each changed file as a single-line row with end-truncated path and right-aligned +additions/-deletions stats for better scanning. --- .../components/instance/instance-shell2.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 23005b9d..5e2ed131 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -1005,12 +1005,18 @@ const InstanceShell2: Component = (props) => { {(item) => (
-
- {item.file} -
-
- {`+${item.additions}`} - {`-${item.deletions}`} +
+
+ {item.file} +
+
+ {`+${item.additions}`} + {`-${item.deletions}`} +
)} From d143faf8eb1b9b7b4d716198e121e41568aa9e2a Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Mon, 9 Feb 2026 16:12:46 +0000 Subject: [PATCH 04/35] feat(ui): add right panel Changes/Status tabs --- .../components/instance/instance-shell2.tsx | 433 ++++++++++++------ .../ui/src/lib/i18n/messages/en/instance.ts | 10 + .../ui/src/lib/i18n/messages/es/instance.ts | 10 + .../ui/src/lib/i18n/messages/fr/instance.ts | 10 + .../ui/src/lib/i18n/messages/ja/instance.ts | 10 + .../ui/src/lib/i18n/messages/ru/instance.ts | 10 + .../src/lib/i18n/messages/zh-Hans/instance.ts | 10 + packages/ui/src/styles/panels.css | 3 + packages/ui/src/styles/panels/right-panel.css | 358 +++++++++++++++ packages/ui/src/styles/panels/tabs.css | 2 + 10 files changed, 725 insertions(+), 131 deletions(-) create mode 100644 packages/ui/src/styles/panels/right-panel.css diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 5e2ed131..bd977dff 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -93,20 +93,26 @@ const MIN_SESSION_SIDEBAR_WIDTH = 220 const MAX_SESSION_SIDEBAR_WIDTH = 400 const RIGHT_DRAWER_WIDTH = 260 const MIN_RIGHT_DRAWER_WIDTH = 200 -const MAX_RIGHT_DRAWER_WIDTH = 380 +const MAX_RIGHT_DRAWER_WIDTH = 1200 const SESSION_CACHE_LIMIT = 5 const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8" const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1" const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1" const RIGHT_PIN_STORAGE_KEY = "opencode-session-right-drawer-pinned-v1" +const RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v1" type LayoutMode = "desktop" | "tablet" | "phone" +type RightPanelTab = "files" | "status" const clampWidth = (value: number) => Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value)) -const clampRightWidth = (value: number) => Math.min(MAX_RIGHT_DRAWER_WIDTH, Math.max(MIN_RIGHT_DRAWER_WIDTH, value)) +const clampRightWidth = (value: number) => { + const windowMax = typeof window !== "undefined" ? Math.floor(window.innerWidth * 0.7) : MAX_RIGHT_DRAWER_WIDTH + const max = Math.max(MIN_RIGHT_DRAWER_WIDTH, windowMax) + return Math.min(max, Math.max(MIN_RIGHT_DRAWER_WIDTH, value)) +} const getPinStorageKey = (side: "left" | "right") => (side === "left" ? LEFT_PIN_STORAGE_KEY : RIGHT_PIN_STORAGE_KEY) function readStoredPinState(side: "left" | "right", defaultValue: boolean) { if (typeof window === "undefined") return defaultValue @@ -120,11 +126,19 @@ function persistPinState(side: "left" | "right", value: boolean) { window.localStorage.setItem(getPinStorageKey(side), value ? "true" : "false") } +function readStoredRightPanelTab(defaultValue: RightPanelTab): RightPanelTab { + if (typeof window === "undefined") return defaultValue + const stored = window.localStorage.getItem(RIGHT_PANEL_TAB_STORAGE_KEY) + return stored === "status" ? "status" : defaultValue +} + const InstanceShell2: Component = (props) => { const { t } = useI18n() const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH) - const [rightDrawerWidth, setRightDrawerWidth] = createSignal(RIGHT_DRAWER_WIDTH) + const [rightDrawerWidth, setRightDrawerWidth] = createSignal( + typeof window !== "undefined" ? clampRightWidth(window.innerWidth * 0.35) : RIGHT_DRAWER_WIDTH, + ) const [leftPinned, setLeftPinned] = createSignal(true) const [leftOpen, setLeftOpen] = createSignal(true) const [rightPinned, setRightPinned] = createSignal(true) @@ -141,6 +155,7 @@ const InstanceShell2: Component = (props) => { const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | null>(null) const [resizeStartX, setResizeStartX] = createSignal(0) const [resizeStartWidth, setResizeStartWidth] = createSignal(0) + const [rightPanelTab, setRightPanelTab] = createSignal(readStoredRightPanelTab("files")) const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal([ "plan", "background-processes", @@ -148,6 +163,7 @@ const InstanceShell2: Component = (props) => { "lsp", "plugins", ]) + const [selectedFile, setSelectedFile] = createSignal(null) const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal(null) const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false) const [permissionModalOpen, setPermissionModalOpen] = createSignal(false) @@ -168,7 +184,7 @@ const InstanceShell2: Component = (props) => { }) const isPhoneLayout = createMemo(() => layoutMode() === "phone") - const leftPinningSupported = createMemo(() => layoutMode() === "desktop") + const leftPinningSupported = createMemo(() => layoutMode() !== "phone") const rightPinningSupported = createMemo(() => layoutMode() !== "phone") const persistPinIfSupported = (side: "left" | "right", value: boolean) => { @@ -196,11 +212,10 @@ const InstanceShell2: Component = (props) => { break } case "tablet": { - const rightSaved = readStoredPinState("right", true) - setLeftPinned(false) - setLeftOpen(false) - setRightPinned(rightSaved) - setRightOpen(rightSaved) + setLeftPinned(true) + setLeftOpen(true) + setRightPinned(false) + setRightOpen(false) break } default: @@ -232,17 +247,25 @@ const InstanceShell2: Component = (props) => { } } + let didLoadRightWidth = false const savedRight = window.localStorage.getItem(RIGHT_DRAWER_STORAGE_KEY) if (savedRight) { const parsed = Number.parseInt(savedRight, 10) if (Number.isFinite(parsed)) { setRightDrawerWidth(clampRightWidth(parsed)) + didLoadRightWidth = true } } + if (!didLoadRightWidth) { + setRightDrawerWidth(clampRightWidth(window.innerWidth * 0.35)) + } + const handleResize = () => { const width = clampWidth(window.innerWidth * 0.3) setSessionSidebarWidth((current) => clampWidth(current || width)) + const fallbackRight = window.innerWidth * 0.35 + setRightDrawerWidth((current) => clampRightWidth(current || fallbackRight)) measureDrawerHost() } @@ -272,6 +295,11 @@ const InstanceShell2: Component = (props) => { window.localStorage.setItem(RIGHT_DRAWER_STORAGE_KEY, rightDrawerWidth().toString()) }) + createEffect(() => { + if (typeof window === "undefined") return + window.localStorage.setItem(RIGHT_PANEL_TAB_STORAGE_KEY, rightPanelTab()) + }) + createEffect(() => { props.tabBarOffset requestAnimationFrame(() => measureDrawerHost()) @@ -965,19 +993,31 @@ const InstanceShell2: Component = (props) => { ) const RightDrawerContent = () => { - const renderSessionChanges = () => { + const renderFilesTabContent = () => { const sessionId = activeSessionIdForInstance() if (!sessionId || sessionId === "info") { - return

{t("instanceShell.sessionChanges.noSessionSelected")}

+ return ( +
+ {t("instanceShell.sessionChanges.noSessionSelected")} +
+ ) } const diffs = activeSessionDiffs() if (diffs === undefined) { - return

{t("instanceShell.sessionChanges.loading")}

+ return ( +
+ {t("instanceShell.sessionChanges.loading")} +
+ ) } if (!Array.isArray(diffs) || diffs.length === 0) { - return

{t("instanceShell.sessionChanges.empty")}

+ return ( +
+ {t("instanceShell.sessionChanges.empty")} +
+ ) } const sorted = [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) @@ -990,47 +1030,119 @@ const InstanceShell2: Component = (props) => { { additions: 0, deletions: 0 }, ) - return ( -
-
- {t("instanceShell.sessionChanges.filesChanged", { count: sorted.length })} - - {`+${totals.additions}`} - {`-${totals.deletions}`} - -
+ // Select first file by default if none selected + const currentSelected = selectedFile() + const selectedFileData = sorted.find((f) => f.file === currentSelected) || sorted[0] -
-
- - {(item) => ( -
-
-
- {item.file} -
-
- {`+${item.additions}`} - {`-${item.deletions}`} -
+ if (isPhoneLayout()) { + return ( +
+
+ {t("instanceShell.filesShell.mobileSelectorLabel")} + +
+
+
+ {t("instanceShell.filesShell.viewerTitle")} +
+
+ + {t("instanceShell.filesShell.viewerEmpty")}
-
- )} - + } + > + {(file) => ( +
+ {file().file} +

{t("instanceShell.filesShell.viewerPlaceholder")}

+
+ )} + +
+
+
+ ) + } + + return ( +
+
+
+ + {sorted.length} + files + + + +{totals.additions} + additions + + + -{totals.deletions} + deletions +
- +
+
+
+ {t("instanceShell.filesShell.fileListTitle")} + {sorted.length} +
+
+ + {(item) => ( +
setSelectedFile(item.file)} + > +
+
+ {item.file} +
+
+ +{item.additions} + -{item.deletions} +
+
+
+ )} +
+
+
+
+
+ {t("instanceShell.filesShell.viewerTitle")} +
+
+ + {t("instanceShell.filesShell.viewerEmpty")} +
+ } + > + {(file) => ( +
+ {file().file} +

{t("instanceShell.filesShell.viewerPlaceholder")}

+
+ )} + +
+
+
) } @@ -1038,11 +1150,19 @@ const InstanceShell2: Component = (props) => { const renderPlanSectionContent = () => { const sessionId = activeSessionIdForInstance() if (!sessionId || sessionId === "info") { - return

{t("instanceShell.plan.noSessionSelected")}

+ return ( +
+ {t("instanceShell.plan.noSessionSelected")} +
+ ) } const todoState = latestTodoState() if (!todoState) { - return

{t("instanceShell.plan.empty")}

+ return ( +
+ {t("instanceShell.plan.empty")} +
+ ) } return } @@ -1050,17 +1170,21 @@ const InstanceShell2: Component = (props) => { const renderBackgroundProcesses = () => { const processes = backgroundProcessList() if (processes.length === 0) { - return

{t("instanceShell.backgroundProcesses.empty")}

+ return ( +
+ {t("instanceShell.backgroundProcesses.empty")} +
+ ) } return (
{(process) => ( -
-
- {process.title} -
+
+
+ {process.title} +
{t("instanceShell.backgroundProcesses.status", { status: process.status })} @@ -1071,7 +1195,7 @@ const InstanceShell2: Component = (props) => {
-
+
+ +
+ +
+
- - {(activeSession) => ( - - )} -
+
- - - {(section) => ( - - - - {t(section.labelKey)} - - - - - {section.render()} - - - )} - - + {renderFilesTabContent()} + {renderStatusTabContent()}
) @@ -1305,6 +1458,15 @@ const InstanceShell2: Component = (props) => { }, }} > + + - ) - } - return (
-
- - {sorted.length} - files - - - +{totals.additions} - additions - - - -{totals.deletions} - deletions +
+ + + + {selectedFileData?.file || ""} + +
+ + +{totals.additions} + + + -{totals.deletions} + +
-
-
-
- {t("instanceShell.filesShell.fileListTitle")} - {sorted.length} -
-
- - {(item) => ( -
setSelectedFile(item.file)} - > -
-
- {item.file} -
-
- +{item.additions} - -{item.deletions} -
-
+
+ +
+
+ + + +
- )} - -
-
-
-
- {t("instanceShell.filesShell.viewerTitle")} -
- - - - -
-
-
- - {t("instanceShell.filesShell.viewerEmpty")} -
- } - > - {(file) => ( +
+
- Binary file cannot be displayed + {t("instanceShell.filesShell.viewerEmpty")}
} > - + {(file) => ( + + Binary file cannot be displayed +
+ } + > + + + )} - )} - +
+
+ } + > +
+
+
+ + {(item) => ( +
{ + setSelectedFile(item.file) + if (isPhoneLayout()) { + setChangesListOpen(false) + } + }} + > +
+
+ {item.file} +
+
+ +{item.additions} + -{item.deletions} +
+
+
+ )} +
+
+
+ +
-
+ + + + + + +
) @@ -1402,113 +1700,245 @@ const InstanceShell2: Component = (props) => { const parent = getParentPath(browserPath()) const scopeKey = `${props.instance.id}:${worktreeSlugForViewer()}` + const toggleFilesList = () => { + setFilesListTouched(true) + setFilesListOpen((current) => { + const next = !current + persistListOpen("files", next) + return next + }) + } + + const headerDisplayedPath = () => browserSelectedPath() || browserPath() + return (
-
- - {browserPath()} - - - Loading… - - - {(err) => {err()}} - +
+ + +
+ + + {headerDisplayedPath()} + + + + Loading… + + + {(err) => {err()}} + +
-
-
-
- Files - {sorted.length} -
-
- - {(p) => ( -
void loadBrowserEntries(p())}> -
-
- .. -
-
-
- )} -
- - - {(item) => ( -
{ - if (item.type === "directory") { - void loadBrowserEntries(item.path) - return - } - void openBrowserFile(item.path) - }} - title={item.path} - > -
-
- {item.name} -
-
- {item.type} -
-
-
- )} -
-
-
- -
-
- Viewer -
-
- + +
- Select a file to preview -
+ + Select a file to preview +
+ } + > + {(payload) => ( + + )} + } > - {(payload) => ( - + {(err) => ( +
+ {err()} +
)} } > - {(err) => ( -
- {err()} +
+ Loading… +
+ +
+
+ } + > +
+
+
+ + {(p) => ( +
void loadBrowserEntries(p())}> +
+
+ .. +
+
)}
- } - > -
- Loading… + + + {(item) => ( +
{ + if (item.type === "directory") { + void loadBrowserEntries(item.path) + return + } + void openBrowserFile(item.path) + }} + title={item.path} + > +
+
+ {item.name} +
+
+ {item.type} +
+
+
+ )} +
- +
+
-
+ + + + + + +
) @@ -1555,7 +1985,7 @@ const InstanceShell2: Component = (props) => { if (file) { setSelectedFile(file) } - setRightPanelTab("files") + setRightPanelTab("changes") } return ( @@ -1831,18 +2261,18 @@ const InstanceShell2: Component = (props) => { @@ -1864,8 +2294,8 @@ const InstanceShell2: Component = (props) => {
- {renderFilesTabContent()} - {renderBrowserTabContent()} + {renderFilesTabContent()} + {renderBrowserTabContent()} {renderStatusTabContent()}
diff --git a/packages/ui/src/styles/panels/right-panel.css b/packages/ui/src/styles/panels/right-panel.css index 391b6738..0ecaf812 100644 --- a/packages/ui/src/styles/panels/right-panel.css +++ b/packages/ui/src/styles/panels/right-panel.css @@ -70,18 +70,87 @@ /* Files tab layout */ .files-tab-container { - @apply flex flex-col h-full min-h-0 p-3 gap-3; + @apply flex flex-col h-full min-h-0; +} + +/* Split view (file list + viewer) */ +.files-split { + display: grid; + grid-template-columns: var(--files-pane-width, 320px) 10px minmax(0, 1fr); + min-height: 0; + flex: 1 1 auto; +} + +.file-split-handle { + cursor: col-resize; + background-color: transparent; + border-left: 1px solid var(--border-base); + border-right: 1px solid var(--border-base); + user-select: none; + touch-action: none; +} + +.file-split-handle:hover { + background-color: var(--surface-hover); } .files-tab-header { - @apply flex items-center justify-between gap-2 px-1; + @apply flex items-center justify-between gap-2 px-3 py-2 border-b; + border-color: var(--border-base); } +.files-tab-header-row { + @apply flex items-center gap-2 w-full min-w-0; +} + +.files-toggle-button { + @apply text-[11px] px-2 py-1 border border-base transition-colors; + background-color: var(--surface-base); + color: var(--text-secondary); +} + +.files-toggle-button:hover { + background-color: var(--surface-hover); + color: var(--text-primary); +} + +.files-tab-body { + @apply flex flex-col flex-1 min-h-0; + position: relative; +} + +.file-list-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + background-color: var(--surface-secondary); + border-left: 1px solid var(--border-base); + z-index: 5; +} + +.file-list-overlay-header { + @apply flex items-center justify-between gap-2 px-3 py-2 border-b; + border-color: var(--border-base); + background-color: var(--surface-secondary); +} + +/* Overlay title intentionally unused; header shows current path instead. */ + .files-tab-stats { @apply flex items-center gap-3 text-[11px]; color: var(--text-muted); } +.files-tab-selected-path { + @apply text-xs font-mono min-w-0 flex-1 overflow-hidden whitespace-nowrap; + color: var(--text-primary); + text-overflow: ellipsis; + direction: rtl; + text-align: left; + unicode-bidi: plaintext; +} + .files-tab-stat { @apply flex items-center gap-1.5; } @@ -100,15 +169,14 @@ /* File list panel */ .file-list-panel { - @apply rounded-lg border flex flex-col min-h-0; - background-color: var(--surface-secondary); - border-color: var(--border-base); + @apply flex flex-col min-h-0; + background-color: transparent; } .file-list-header { @apply flex items-center justify-between gap-2 px-3 py-2 border-b; border-color: var(--border-base); - background-color: var(--surface-secondary); + background-color: transparent; } .file-list-title { @@ -173,15 +241,14 @@ /* File viewer panel */ .file-viewer-panel { - @apply rounded-lg border flex flex-col min-h-0; - background-color: var(--surface-secondary); - border-color: var(--border-base); + @apply flex flex-col min-h-0; + background-color: transparent; } .file-viewer-header { @apply flex items-center gap-2 px-3 py-2 border-b; border-color: var(--border-base); - background-color: var(--surface-secondary); + background-color: transparent; } .file-viewer-toolbar { From d291c2f074cd80e78a9bec757fe7a4fe0edb94d4 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Tue, 10 Feb 2026 20:37:41 +0000 Subject: [PATCH 20/35] fix(ui): avoid Monaco overlay dimming on phone --- .../components/instance/instance-shell2.tsx | 34 ------------------- packages/ui/src/styles/panels/right-panel.css | 12 +++---- 2 files changed, 4 insertions(+), 42 deletions(-) diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 24e26297..57d35376 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -1631,23 +1631,6 @@ const InstanceShell2: Component = (props) => { - ) - } - - const renderBrowserTabContent = () => { - if (browserLoading() && browserEntries() === null) { - return ( -
- Loading files... -
- ) - } - - const entries = browserEntries() || [] - const sorted = [...entries].sort((a, b) => { - const aDir = a.type === "directory" ? 0 : 1 - const bDir = b.type === "directory" ? 0 : 1 - if (aDir !== bDir) return aDir - bDir - return String(a.name || "").localeCompare(String(b.name || "")) - }) - - const parent = getParentPath(browserPath()) - const scopeKey = `${props.instance.id}:${worktreeSlugForViewer()}` - - const toggleFilesList = () => { - setFilesListTouched(true) - setFilesListOpen((current) => { - const next = !current - persistListOpen("files", next) - return next - }) - } - - const headerDisplayedPath = () => browserSelectedPath() || browserPath() - - const refreshFilesTab = async () => { - void loadBrowserEntries(browserPath()) - const selected = browserSelectedPath() - if (selected) { - // Refresh file content without altering overlay state. - setBrowserSelectedLoading(true) - setBrowserSelectedError(null) - try { - const content = await requestData(browserClient().file.read({ path: selected }), "file.read") - const type = (content as any)?.type - const encoding = (content as any)?.encoding - if (type && type !== "text") { - throw new Error("Binary file cannot be displayed") - } - if (encoding === "base64") { - throw new Error("Binary file cannot be displayed") - } - const text = (content as any)?.content - if (typeof text !== "string") { - throw new Error("Unsupported file type") - } - setBrowserSelectedContent(text) - } catch (error) { - setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file") - } finally { - setBrowserSelectedLoading(false) - } - } - } - - return ( -
-
-
- - -
- - - {headerDisplayedPath()} - - - - Loading… - - - {(err) => {err()}} - -
- - -
-
- -
- -
- - Select a file to preview -
- } - > - {(payload) => ( - - )} -
- } - > - {(err) => ( -
- {err()} -
- )} - - } - > -
- Loading… -
- -
-
- } - > -
-
-
- - {(p) => ( -
void loadBrowserEntries(p())}> -
-
- .. -
-
-
- )} -
- - - {(item) => ( -
{ - if (item.type === "directory") { - void loadBrowserEntries(item.path) - return - } - void openBrowserFile(item.path) - }} - title={item.path} - > -
-
- {item.name} -
-
- {item.type} -
-
-
- )} -
-
-
- -
-
- - - - - - -
-
- ) - } - - const renderGitChangesTabContent = () => { - const sessionId = activeSessionIdForInstance() - if (!sessionId || sessionId === "info") { - return ( -
- Select a session to view changes. -
- ) - } - - const entries = gitStatusEntries() - if (entries === null) { - return ( -
- Loading git changes… -
- ) - } - - const nonDeleted = entries.filter((item) => item && item.status !== "deleted") - if (nonDeleted.length === 0) { - return ( -
- No git changes yet. -
- ) - } - - const sorted = [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || ""))) - const totals = sorted.reduce( - (acc, item) => { - acc.additions += typeof item.added === "number" ? item.added : 0 - acc.deletions += typeof item.removed === "number" ? item.removed : 0 - return acc - }, - { additions: 0, deletions: 0 }, - ) - - const selectedPath = gitSelectedPath() - const fallbackPath = gitMostChangedPath() - const selectedEntry = - sorted.find((item) => item.path === selectedPath) || (fallbackPath ? sorted.find((item) => item.path === fallbackPath) : null) - const scopeKey = `${props.instance.id}:git:${worktreeSlugForViewer()}` - - const toggleGitList = () => { - setGitChangesListTouched(true) - setGitChangesListOpen((current) => { - const next = !current - persistListOpen("git-changes", next) - return next - }) - } - - return ( -
-
-
- - - - {selectedEntry?.path || ""} - - -
- - +{totals.additions} - - - -{totals.deletions} - - - {(err) => {err()}} - -
- - -
-
- -
- -
-
- - - - -
-
-
- - No file selected. -
- } - > - {(file) => ( - - )} -
- } - > - {(err) => ( -
- {err()} -
- )} - - } - > -
- Loading… -
- -
-
- } - > -
-
-
- - {(item) => ( -
{ - void openGitFile(item.path) - }} - > -
-
- {item.path} -
-
- - deleted - - - <> - +{item.added} - -{item.removed} - - -
-
-
- )} -
-
-
- -
- - - - - - - -
- - ) - } - - const renderStatusSessionChanges = () => { - const sessionId = activeSessionIdForInstance() - if (!sessionId || sessionId === "info") { - return ( -
- {t("instanceShell.sessionChanges.noSessionSelected")} -
- ) - } - - const diffs = activeSessionDiffs() - if (diffs === undefined) { - return ( -
- {t("instanceShell.sessionChanges.loading")} -
- ) - } - - if (!Array.isArray(diffs) || diffs.length === 0) { - return ( -
- {t("instanceShell.sessionChanges.empty")} -
- ) - } - - const sorted = [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) - const totals = sorted.reduce( - (acc, item) => { - acc.additions += typeof item.additions === "number" ? item.additions : 0 - acc.deletions += typeof item.deletions === "number" ? item.deletions : 0 - return acc - }, - { additions: 0, deletions: 0 }, - ) - - const openChangesTab = (file?: string) => { - if (file) { - setSelectedFile(file) - } - setRightPanelTab("changes") - } - - return ( -
-
- {t("instanceShell.sessionChanges.filesChanged", { count: sorted.length })} - - {`+${totals.additions}`} - {`-${totals.deletions}`} - -
- -
-
- - {(item) => ( - - )} - -
-
-
- ) - } - - const renderPlanSectionContent = () => { - const sessionId = activeSessionIdForInstance() - if (!sessionId || sessionId === "info") { - return ( -
- {t("instanceShell.plan.noSessionSelected")} -
- ) - } - const todoState = latestTodoState() - if (!todoState) { - return ( -
- {t("instanceShell.plan.empty")} -
- ) - } - return - } - - const renderBackgroundProcesses = () => { - const processes = backgroundProcessList() - if (processes.length === 0) { - return ( -
- {t("instanceShell.backgroundProcesses.empty")} -
- ) - } - - return ( -
- - {(process) => ( -
-
- {process.title} -
- {t("instanceShell.backgroundProcesses.status", { status: process.status })} - - - {t("instanceShell.backgroundProcesses.output", { - sizeKb: Math.round((process.outputSizeBytes ?? 0) / 1024), - })} - - -
-
-
- - - -
-
- )} -
-
- ) - } - - const statusSections = [ - { - id: "session-changes", - labelKey: "instanceShell.rightPanel.sections.sessionChanges", - render: renderStatusSessionChanges, - }, - { - id: "plan", - labelKey: "instanceShell.rightPanel.sections.plan", - render: renderPlanSectionContent, - }, - { - id: "background-processes", - labelKey: "instanceShell.rightPanel.sections.backgroundProcesses", - render: renderBackgroundProcesses, - }, - { - id: "mcp", - labelKey: "instanceShell.rightPanel.sections.mcp", - render: () => ( - - ), - }, - { - id: "lsp", - labelKey: "instanceShell.rightPanel.sections.lsp", - render: () => ( - - ), - }, - { - id: "plugins", - labelKey: "instanceShell.rightPanel.sections.plugins", - render: () => ( - - ), - }, - ] - - createEffect(() => { - const currentExpanded = new Set(rightPanelExpandedItems()) - if (statusSections.every((section) => currentExpanded.has(section.id))) return - setRightPanelExpandedItems(statusSections.map((section) => section.id)) - }) - - const handleAccordionChange = (values: string[]) => { - setRightPanelExpandedItems(values) - } - - const isSectionExpanded = (id: string) => rightPanelExpandedItems().includes(id) - - const renderStatusTabContent = () => ( -
- - {(activeSession) => ( - - )} - - - - - {(section) => ( - - - - {t(section.labelKey)} - - - - - {section.render()} - - - )} - - -
- ) - - const tabClass = (tab: RightPanelTab) => - `right-panel-tab ${rightPanelTab() === tab ? "right-panel-tab-active" : "right-panel-tab-inactive"}` - - return ( -
-
-
-
- - - - - - - (rightPinned() ? unpinRightDrawer() : pinRightDrawer())} - > - {rightPinned() ? : } - - -
-
-
-
- - - - -
- -
-
-
-
-
- -
- {renderFilesTabContent()} - {renderGitChangesTabContent()} - {renderBrowserTabContent()} - {renderStatusTabContent()} -
-
- ) - } - const renderLeftPanel = () => { if (leftPinned()) { return ( @@ -2869,7 +328,27 @@ const InstanceShell2: Component = (props) => { role="presentation" aria-hidden="true" /> - + setShowSessionSearch((current) => !current)} + keyboardShortcuts={keyboardShortcuts} + isPhoneLayout={isPhoneLayout} + drawerState={leftDrawerState} + leftPinned={leftPinned} + onSelectSession={handleSessionSelect} + onNewSession={props.onNewSession} + onSidebarAgentChange={props.handleSidebarAgentChange} + onSidebarModelChange={props.handleSidebarModelChange} + onPinLeftDrawer={pinLeftDrawer} + onUnpinLeftDrawer={unpinLeftDrawer} + onCloseLeftDrawer={closeLeftDrawer} + setContentEl={setLeftDrawerContentEl} + /> ) } @@ -2910,7 +389,27 @@ const InstanceShell2: Component = (props) => { aria-hidden="true" /> - + setShowSessionSearch((current) => !current)} + keyboardShortcuts={keyboardShortcuts} + isPhoneLayout={isPhoneLayout} + drawerState={leftDrawerState} + leftPinned={leftPinned} + onSelectSession={handleSessionSelect} + onNewSession={props.onNewSession} + onSidebarAgentChange={props.handleSidebarAgentChange} + onSidebarModelChange={props.handleSidebarModelChange} + onPinLeftDrawer={pinLeftDrawer} + onUnpinLeftDrawer={unpinLeftDrawer} + onCloseLeftDrawer={closeLeftDrawer} + setContentEl={setLeftDrawerContentEl} + /> ) } @@ -2938,7 +437,28 @@ const InstanceShell2: Component = (props) => { role="presentation" aria-hidden="true" /> - + ) } @@ -2978,7 +498,28 @@ const InstanceShell2: Component = (props) => { aria-hidden="true" /> - + ) diff --git a/packages/ui/src/components/instance/shell/SessionSidebar.tsx b/packages/ui/src/components/instance/shell/SessionSidebar.tsx new file mode 100644 index 00000000..affce5dd --- /dev/null +++ b/packages/ui/src/components/instance/shell/SessionSidebar.tsx @@ -0,0 +1,168 @@ +import { Show, type Accessor, type Component } from "solid-js" +import type { SessionThread } from "../../../stores/session-state" +import type { Session } from "../../../types/session" +import type { KeyboardShortcut } from "../../../lib/keyboard-registry" +import type { DrawerViewState } from "./types" + +import { Search } from "lucide-solid" +import IconButton from "@suid/material/IconButton" +import MenuOpenIcon from "@suid/icons-material/MenuOpen" +import PushPinIcon from "@suid/icons-material/PushPin" +import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined" +import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined" + +import SessionList from "../../session-list" +import KeyboardHint from "../../keyboard-hint" +import Kbd from "../../kbd" +import WorktreeSelector from "../../worktree-selector" +import AgentSelector from "../../agent-selector" +import ModelSelector from "../../model-selector" +import ThinkingSelector from "../../thinking-selector" +import { getLogger } from "../../../lib/logger" + +const log = getLogger("session") + +interface SessionSidebarProps { + t: (key: string) => string + instanceId: string + threads: Accessor + activeSessionId: Accessor + activeSession: Accessor + + showSearch: Accessor + onToggleSearch: () => void + + keyboardShortcuts: Accessor + isPhoneLayout: Accessor + drawerState: Accessor + leftPinned: Accessor + + onSelectSession: (sessionId: string) => void + onNewSession: () => Promise | void + onSidebarAgentChange: (sessionId: string, agent: string) => Promise + onSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise + onPinLeftDrawer: () => void + onUnpinLeftDrawer: () => void + onCloseLeftDrawer: () => void + + setContentEl: (el: HTMLElement | null) => void +} + +const SessionSidebar: Component = (props) => ( +
+
+
+ + {props.t("instanceShell.leftPanel.sessionsTitle")} + +
+ + + + props.onSelectSession("info")} + > + + + + (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())} + > + {props.leftPinned() ? : } + + + + + + + +
+
+
+ + + +
+
+ +
+ { + const result = props.onNewSession() + if (result instanceof Promise) { + void result.catch((error) => log.error("Failed to create session:", error)) + } + }} + enableFilterBar={props.showSearch()} + showHeader={false} + showFooter={false} + /> + +
+ + {(activeSession) => ( + <> +
+ + + props.onSidebarAgentChange(activeSession().id, agent)} + /> + + props.onSidebarModelChange(activeSession().id, model)} + /> + + + + +
+ + )} +
+
+
+) + +export default SessionSidebar diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx new file mode 100644 index 00000000..5e240088 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -0,0 +1,829 @@ +import { + Show, + createEffect, + createMemo, + createSignal, + onCleanup, + type Accessor, + type Component, +} from "solid-js" +import type { ToolState } from "@opencode-ai/sdk" +import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client" +import IconButton from "@suid/material/IconButton" +import MenuOpenIcon from "@suid/icons-material/MenuOpen" +import PushPinIcon from "@suid/icons-material/PushPin" +import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined" + +import type { Instance } from "../../../../types/instance" +import type { BackgroundProcess } from "../../../../../../server/src/api-types" +import type { Session } from "../../../../types/session" +import type { DrawerViewState } from "../types" +import type { DiffContextMode, DiffViewMode, RightPanelTab } from "./types" + +import ChangesTab from "./tabs/ChangesTab" +import FilesTab from "./tabs/FilesTab" +import GitChangesTab from "./tabs/GitChangesTab" +import StatusTab from "./tabs/StatusTab" + +import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees" +import { requestData } from "../../../../lib/opencode-api" +import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse" +import { useGlobalPointerDrag } from "../useGlobalPointerDrag" +import { + RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, + RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, + RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY, + RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY, + RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, + RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY, + RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY, + RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, + RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY, + RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY, + RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, + RIGHT_PANEL_TAB_STORAGE_KEY, + readStoredBool, + readStoredEnum, + readStoredPanelWidth, + readStoredRightPanelTab, +} from "../storage" + +interface RightPanelProps { + t: (key: string, vars?: Record) => string + + instanceId: string + instance: Instance + + activeSessionId: Accessor + activeSession: Accessor + activeSessionDiffs: Accessor + + latestTodoState: Accessor + backgroundProcessList: Accessor + onOpenBackgroundOutput: (process: BackgroundProcess) => void + onStopBackgroundProcess: (processId: string) => Promise | void + onTerminateBackgroundProcess: (processId: string) => Promise | void + + isPhoneLayout: Accessor + rightDrawerWidth: Accessor + rightDrawerWidthInitialized: Accessor + rightDrawerState: Accessor + rightPinned: Accessor + onCloseRightDrawer: () => void + onPinRightDrawer: () => void + onUnpinRightDrawer: () => void + + setContentEl: (el: HTMLElement | null) => void +} + +const RightPanel: Component = (props) => { + const [rightPanelTab, setRightPanelTab] = createSignal(readStoredRightPanelTab("changes")) + const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal([ + "plan", + "background-processes", + "mcp", + "lsp", + "plugins", + ]) + const [selectedFile, setSelectedFile] = createSignal(null) + + const [browserPath, setBrowserPath] = createSignal(".") + const [browserEntries, setBrowserEntries] = createSignal(null) + const [browserLoading, setBrowserLoading] = createSignal(false) + const [browserError, setBrowserError] = createSignal(null) + const [browserSelectedPath, setBrowserSelectedPath] = createSignal(null) + const [browserSelectedContent, setBrowserSelectedContent] = createSignal(null) + const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false) + const [browserSelectedError, setBrowserSelectedError] = createSignal(null) + + const [diffViewMode, setDiffViewMode] = createSignal( + readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified", + ) + const [diffContextMode, setDiffContextMode] = createSignal( + readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed", + ) + + const [changesSplitWidth, setChangesSplitWidth] = createSignal(320) + const [filesSplitWidth, setFilesSplitWidth] = createSignal(320) + const [gitChangesSplitWidth, setGitChangesSplitWidth] = createSignal(320) + const [activeSplitResize, setActiveSplitResize] = createSignal<"changes" | "git-changes" | "files" | null>(null) + const [splitResizeStartX, setSplitResizeStartX] = createSignal(0) + const [splitResizeStartWidth, setSplitResizeStartWidth] = createSignal(0) + + const [filesListOpen, setFilesListOpen] = createSignal(true) + const [filesListTouched, setFilesListTouched] = createSignal(false) + const [changesListOpen, setChangesListOpen] = createSignal(true) + const [changesListTouched, setChangesListTouched] = createSignal(false) + const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true) + const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false) + + const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone")) + + const listOpenStorageKey = (tab: "changes" | "git-changes" | "files") => { + const layout = listLayoutKey() + if (tab === "changes") { + return layout === "phone" ? RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY + } + if (tab === "git-changes") { + return layout === "phone" + ? RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY + : RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY + } + return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY + } + + const persistListOpen = (tab: "changes" | "git-changes" | "files", value: boolean) => { + if (typeof window === "undefined") return + window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false") + } + + createEffect(() => { + // Refresh persisted visibility when layout changes (phone vs non-phone). + const layout = listLayoutKey() + layout + + const filesPersisted = readStoredBool(listOpenStorageKey("files")) + if (filesPersisted !== null) { + setFilesListOpen(filesPersisted) + setFilesListTouched(true) + } else { + setFilesListOpen(true) + setFilesListTouched(false) + } + + const changesPersisted = readStoredBool(listOpenStorageKey("changes")) + if (changesPersisted !== null) { + setChangesListOpen(changesPersisted) + setChangesListTouched(true) + } else { + setChangesListOpen(true) + setChangesListTouched(false) + } + + const gitPersisted = readStoredBool(listOpenStorageKey("git-changes")) + if (gitPersisted !== null) { + setGitChangesListOpen(gitPersisted) + setGitChangesListTouched(true) + } else { + setGitChangesListOpen(true) + setGitChangesListTouched(false) + } + }) + + createEffect(() => { + // Default behavior: when nothing is selected, keep the file list open. + // Once the user explicitly toggles it, we stop auto-opening. + if (rightPanelTab() !== "files") return + if (filesListTouched()) return + if (!browserSelectedPath()) { + setFilesListOpen(true) + } + }) + + createEffect(() => { + if (typeof window === "undefined") return + window.localStorage.setItem(RIGHT_PANEL_TAB_STORAGE_KEY, rightPanelTab()) + }) + + createEffect(() => { + if (typeof window === "undefined") return + window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, diffViewMode()) + }) + + createEffect(() => { + if (typeof window === "undefined") return + window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode()) + }) + + const clampSplitWidth = (value: number) => { + const min = 200 + const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65)) + const max = Math.min(560, maxByDrawer) + return Math.min(max, Math.max(min, Math.floor(value))) + } + + const [splitWidthsInitialized, setSplitWidthsInitialized] = createSignal(false) + + createEffect(() => { + if (splitWidthsInitialized()) return + if (!props.rightDrawerWidthInitialized()) return + setSplitWidthsInitialized(true) + setChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, 320))) + setFilesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, 320))) + setGitChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, 320))) + }) + + const persistSplitWidth = (mode: "changes" | "git-changes" | "files", width: number) => { + if (typeof window === "undefined") return + const key = + mode === "changes" + ? RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY + : mode === "git-changes" + ? RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY + : RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY + window.localStorage.setItem(key, String(width)) + } + + function stopSplitResize() { + setActiveSplitResize(null) + if (typeof document === "undefined") return + splitPointerDrag.stop() + } + + function splitMouseMove(event: MouseEvent) { + const mode = activeSplitResize() + if (!mode) return + event.preventDefault() + const delta = event.clientX - splitResizeStartX() + const next = clampSplitWidth(splitResizeStartWidth() + delta) + if (mode === "changes") setChangesSplitWidth(next) + else if (mode === "git-changes") setGitChangesSplitWidth(next) + else setFilesSplitWidth(next) + } + + function splitMouseUp() { + const mode = activeSplitResize() + if (mode) { + const width = + mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth() + persistSplitWidth(mode, width) + } + stopSplitResize() + } + + function splitTouchMove(event: TouchEvent) { + const mode = activeSplitResize() + if (!mode) return + const touch = event.touches[0] + if (!touch) return + event.preventDefault() + const delta = touch.clientX - splitResizeStartX() + const next = clampSplitWidth(splitResizeStartWidth() + delta) + if (mode === "changes") setChangesSplitWidth(next) + else if (mode === "git-changes") setGitChangesSplitWidth(next) + else setFilesSplitWidth(next) + } + + function splitTouchEnd() { + const mode = activeSplitResize() + if (mode) { + const width = + mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth() + persistSplitWidth(mode, width) + } + stopSplitResize() + } + + const splitPointerDrag = useGlobalPointerDrag({ + onMouseMove: splitMouseMove, + onMouseUp: splitMouseUp, + onTouchMove: splitTouchMove, + onTouchEnd: splitTouchEnd, + }) + + const startSplitResize = (mode: "changes" | "git-changes" | "files", clientX: number) => { + if (typeof document === "undefined") return + setActiveSplitResize(mode) + setSplitResizeStartX(clientX) + setSplitResizeStartWidth( + mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth(), + ) + splitPointerDrag.start() + } + + const handleSplitResizeMouseDown = (mode: "changes" | "git-changes" | "files") => (event: MouseEvent) => { + event.preventDefault() + startSplitResize(mode, event.clientX) + } + + const handleSplitResizeTouchStart = (mode: "changes" | "git-changes" | "files") => (event: TouchEvent) => { + const touch = event.touches[0] + if (!touch) return + event.preventDefault() + startSplitResize(mode, touch.clientX) + } + + onCleanup(() => { + stopSplitResize() + }) + + const worktreeSlugForViewer = createMemo(() => { + const sessionId = props.activeSessionId() + if (sessionId && sessionId !== "info") { + return getWorktreeSlugForSession(props.instanceId, sessionId) + } + return getDefaultWorktreeSlug(props.instanceId) + }) + + const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer())) + + const [gitStatusEntries, setGitStatusEntries] = createSignal(null) + const [gitStatusLoading, setGitStatusLoading] = createSignal(false) + const [gitStatusError, setGitStatusError] = createSignal(null) + const [gitSelectedPath, setGitSelectedPath] = createSignal(null) + const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false) + const [gitSelectedError, setGitSelectedError] = createSignal(null) + const [gitSelectedBefore, setGitSelectedBefore] = createSignal(null) + const [gitSelectedAfter, setGitSelectedAfter] = createSignal(null) + + const gitMostChangedPath = createMemo(() => { + const entries = gitStatusEntries() + if (!Array.isArray(entries) || entries.length === 0) return null + const candidates = entries.filter((item) => item && item.status !== "deleted") + if (candidates.length === 0) return null + const best = candidates.reduce((currentBest, item) => { + const bestScore = (currentBest?.added ?? 0) + (currentBest?.removed ?? 0) + const score = (item?.added ?? 0) + (item?.removed ?? 0) + if (score > bestScore) return item + if (score < bestScore) return currentBest + return String(item.path || "").localeCompare(String(currentBest?.path || "")) < 0 ? item : currentBest + }, candidates[0]) + return typeof best?.path === "string" ? best.path : null + }) + + createEffect(() => { + // Reset tab state when worktree context changes. + worktreeSlugForViewer() + setBrowserPath(".") + setBrowserEntries(null) + setBrowserError(null) + setBrowserSelectedPath(null) + setBrowserSelectedContent(null) + setBrowserSelectedError(null) + setBrowserSelectedLoading(false) + + setGitStatusEntries(null) + setGitStatusError(null) + setGitStatusLoading(false) + setGitSelectedPath(null) + setGitSelectedLoading(false) + setGitSelectedError(null) + setGitSelectedBefore(null) + setGitSelectedAfter(null) + }) + + const loadGitStatus = async (force = false) => { + if (!force && gitStatusEntries() !== null) return + setGitStatusLoading(true) + setGitStatusError(null) + try { + const list = await requestData(browserClient().file.status(), "file.status") + setGitStatusEntries(Array.isArray(list) ? list : []) + } catch (error) { + setGitStatusError(error instanceof Error ? error.message : "Failed to load git status") + setGitStatusEntries([]) + } finally { + setGitStatusLoading(false) + } + } + + async function openGitFile(path: string) { + setGitSelectedPath(path) + setGitSelectedLoading(true) + setGitSelectedError(null) + setGitSelectedBefore(null) + setGitSelectedAfter(null) + + const list = gitStatusEntries() || [] + const entry = list.find((item) => item.path === path) || null + if (entry?.status === "deleted") { + setGitSelectedError("Deleted file diff is not available yet") + setGitSelectedLoading(false) + return + } + + // Phone: treat file selection as a commit action and close the overlay. + if (props.isPhoneLayout()) { + setGitChangesListOpen(false) + } + + try { + const content = await requestData(browserClient().file.read({ path }), "file.read") + const type = (content as any)?.type + const encoding = (content as any)?.encoding + if (type && type !== "text") { + throw new Error("Binary file cannot be displayed") + } + if (encoding === "base64") { + throw new Error("Binary file cannot be displayed") + } + const afterText = typeof (content as any)?.content === "string" ? ((content as any).content as string) : null + if (afterText === null) { + throw new Error("Unsupported file type") + } + + setGitSelectedAfter(afterText) + + if (entry?.status === "added") { + setGitSelectedBefore("") + return + } + + const diffText = + typeof (content as any)?.diff === "string" && String((content as any).diff).trim().length > 0 + ? String((content as any).diff) + : (content as any)?.patch + ? buildUnifiedDiffFromSdkPatch((content as any).patch) + : "" + + const beforeText = tryReverseApplyUnifiedDiff(afterText, diffText) + if (beforeText === null) { + throw new Error("Unable to calculate diff for this file") + } + setGitSelectedBefore(beforeText) + } catch (error) { + setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes") + } finally { + setGitSelectedLoading(false) + } + } + + createEffect(() => { + if (rightPanelTab() !== "git-changes") return + const entries = gitStatusEntries() + if (entries === null) return + if (gitSelectedPath()) return + const next = gitMostChangedPath() + if (!next) return + void openGitFile(next) + }) + + const refreshGitStatus = async () => { + await loadGitStatus(true) + const selected = gitSelectedPath() + if (selected) { + void openGitFile(selected) + } + } + + const bestDiffFile = createMemo(() => { + const diffs = props.activeSessionDiffs() + if (!Array.isArray(diffs) || diffs.length === 0) return null + const best = diffs.reduce((currentBest, item) => { + const bestAdd = typeof (currentBest as any)?.additions === "number" ? (currentBest as any).additions : 0 + const bestDel = typeof (currentBest as any)?.deletions === "number" ? (currentBest as any).deletions : 0 + const bestScore = bestAdd + bestDel + + const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0 + const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0 + const score = add + del + + if (score > bestScore) return item + if (score < bestScore) return currentBest + return String(item.file || "").localeCompare(String((currentBest as any)?.file || "")) < 0 ? item : currentBest + }, diffs[0]) + return typeof (best as any)?.file === "string" ? (best as any).file : null + }) + + createEffect(() => { + const next = bestDiffFile() + if (!next) return + const diffs = props.activeSessionDiffs() + if (!Array.isArray(diffs) || diffs.length === 0) return + + const current = selectedFile() + if (current && diffs.some((d) => d.file === current)) return + setSelectedFile(next) + }) + + const normalizeBrowserPath = (input: string) => { + const raw = String(input || ".").trim() + if (!raw || raw === "./") return "." + const cleaned = raw.replace(/\\/g, "/").replace(/\/+$/, "") + return cleaned === "" ? "." : cleaned + } + + const getParentPath = (path: string): string | null => { + const current = normalizeBrowserPath(path) + if (current === ".") return null + const parts = current.split("/").filter(Boolean) + parts.pop() + return parts.length ? parts.join("/") : "." + } + + const loadBrowserEntries = async (path: string) => { + const normalized = normalizeBrowserPath(path) + setBrowserLoading(true) + setBrowserError(null) + try { + const nodes = await requestData(browserClient().file.list({ path: normalized }), "file.list") + setBrowserPath(normalized) + setBrowserEntries(Array.isArray(nodes) ? nodes : []) + } catch (error) { + setBrowserError(error instanceof Error ? error.message : "Failed to load files") + setBrowserEntries([]) + } finally { + setBrowserLoading(false) + } + } + + const openBrowserFile = async (path: string) => { + setBrowserSelectedPath(path) + setBrowserSelectedLoading(true) + setBrowserSelectedError(null) + setBrowserSelectedContent(null) + + // Phone: treat file selection as a commit action and close the overlay. + if (props.isPhoneLayout()) { + setFilesListOpen(false) + } + try { + const content = await requestData(browserClient().file.read({ path }), "file.read") + const type = (content as any)?.type + const encoding = (content as any)?.encoding + if (type && type !== "text") { + throw new Error("Binary file cannot be displayed") + } + if (encoding === "base64") { + throw new Error("Binary file cannot be displayed") + } + const text = (content as any)?.content + if (typeof text !== "string") { + throw new Error("Unsupported file type") + } + setBrowserSelectedContent(text) + } catch (error) { + setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file") + } finally { + setBrowserSelectedLoading(false) + } + } + + createEffect(() => { + if (rightPanelTab() !== "files") return + if (browserLoading()) return + if (browserEntries() !== null) return + void loadBrowserEntries(browserPath()) + }) + + createEffect(() => { + if (rightPanelTab() !== "git-changes") return + if (gitStatusLoading()) return + if (gitStatusEntries() !== null) return + void loadGitStatus() + }) + + const handleSelectChangesFile = (file: string, closeList: boolean) => { + setSelectedFile(file) + if (closeList) { + setChangesListOpen(false) + } + } + + const toggleChangesList = () => { + setChangesListTouched(true) + setChangesListOpen((current) => { + const next = !current + persistListOpen("changes", next) + return next + }) + } + + const toggleFilesList = () => { + setFilesListTouched(true) + setFilesListOpen((current) => { + const next = !current + persistListOpen("files", next) + return next + }) + } + + const toggleGitList = () => { + setGitChangesListTouched(true) + setGitChangesListOpen((current) => { + const next = !current + persistListOpen("git-changes", next) + return next + }) + } + + const refreshFilesTab = async () => { + void loadBrowserEntries(browserPath()) + const selected = browserSelectedPath() + if (selected) { + // Refresh file content without altering overlay state. + setBrowserSelectedLoading(true) + setBrowserSelectedError(null) + try { + const content = await requestData(browserClient().file.read({ path: selected }), "file.read") + const type = (content as any)?.type + const encoding = (content as any)?.encoding + if (type && type !== "text") { + throw new Error("Binary file cannot be displayed") + } + if (encoding === "base64") { + throw new Error("Binary file cannot be displayed") + } + const text = (content as any)?.content + if (typeof text !== "string") { + throw new Error("Unsupported file type") + } + setBrowserSelectedContent(text) + } catch (error) { + setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file") + } finally { + setBrowserSelectedLoading(false) + } + } + } + + const browserParentPath = createMemo(() => getParentPath(browserPath())) + const browserScopeKey = createMemo(() => `${props.instanceId}:${worktreeSlugForViewer()}`) + const gitScopeKey = createMemo(() => `${props.instanceId}:git:${worktreeSlugForViewer()}`) + + const openChangesTabFromStatus = (file?: string) => { + if (file) { + setSelectedFile(file) + } + setRightPanelTab("changes") + } + + const statusSectionIds = ["session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"] + + createEffect(() => { + const currentExpanded = new Set(rightPanelExpandedItems()) + if (statusSectionIds.every((id) => currentExpanded.has(id))) return + setRightPanelExpandedItems(statusSectionIds) + }) + + const handleAccordionChange = (values: string[]) => { + setRightPanelExpandedItems(values) + } + + const tabClass = (tab: RightPanelTab) => + `right-panel-tab ${rightPanelTab() === tab ? "right-panel-tab-active" : "right-panel-tab-inactive"}` + + return ( +
+
+
+
+ + + + + + + (props.rightPinned() ? props.onUnpinRightDrawer() : props.onPinRightDrawer())} + > + {props.rightPinned() ? : } + + +
+
+
+
+ + + + +
+ +
+
+
+
+
+ +
+ + + + + + void openGitFile(path)} + onRefresh={() => void refreshGitStatus()} + listOpen={gitChangesListOpen} + onToggleList={toggleGitList} + splitWidth={gitChangesSplitWidth} + onResizeMouseDown={handleSplitResizeMouseDown("git-changes")} + onResizeTouchStart={handleSplitResizeTouchStart("git-changes")} + isPhoneLayout={props.isPhoneLayout} + /> + + + + void loadBrowserEntries(path)} + onOpenFile={(path) => void openBrowserFile(path)} + onRefresh={() => void refreshFilesTab()} + listOpen={filesListOpen} + onToggleList={toggleFilesList} + splitWidth={filesSplitWidth} + onResizeMouseDown={handleSplitResizeMouseDown("files")} + onResizeTouchStart={handleSplitResizeTouchStart("files")} + isPhoneLayout={props.isPhoneLayout} + /> + + + + + +
+
+ ) +} + +export default RightPanel diff --git a/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx b/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx new file mode 100644 index 00000000..beb249ee --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx @@ -0,0 +1,53 @@ +import type { Component } from "solid-js" + +import type { DiffContextMode, DiffViewMode } from "../types" + +interface DiffToolbarProps { + viewMode: DiffViewMode + contextMode: DiffContextMode + onViewModeChange: (mode: DiffViewMode) => void + onContextModeChange: (mode: DiffContextMode) => void +} + +const DiffToolbar: Component = (props) => { + return ( +
+ + + + +
+ ) +} + +export default DiffToolbar diff --git a/packages/ui/src/components/instance/shell/right-panel/components/OverlayList.tsx b/packages/ui/src/components/instance/shell/right-panel/components/OverlayList.tsx new file mode 100644 index 00000000..43299f68 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/components/OverlayList.tsx @@ -0,0 +1,16 @@ +import type { Component, JSX } from "solid-js" + +interface OverlayListProps { + ariaLabel: string + children: JSX.Element +} + +const OverlayList: Component = (props) => { + return ( + + ) +} + +export default OverlayList diff --git a/packages/ui/src/components/instance/shell/right-panel/components/SplitFilePanel.tsx b/packages/ui/src/components/instance/shell/right-panel/components/SplitFilePanel.tsx new file mode 100644 index 00000000..56fd6211 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/components/SplitFilePanel.tsx @@ -0,0 +1,70 @@ +import { Show, type Component, type JSX } from "solid-js" + +import OverlayList from "./OverlayList" + +type SplitFilePanelList = { + panel: () => JSX.Element + overlay: () => JSX.Element +} + +interface SplitFilePanelProps { + header: JSX.Element + list: SplitFilePanelList + viewer: JSX.Element + + listOpen: boolean + onToggleList: () => void + + splitWidth: number + onResizeMouseDown: (event: MouseEvent) => void + onResizeTouchStart: (event: TouchEvent) => void + + isPhoneLayout: boolean + overlayAriaLabel: string +} + +const SplitFilePanel: Component = (props) => { + return ( +
+
+
+ + + {props.header} +
+
+ +
+ +
+
+
{props.list.panel()}
+
+ + + + + + {props.list.overlay()} + + +
+
+ ) +} + +export default SplitFilePanel diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx new file mode 100644 index 00000000..08109ead --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx @@ -0,0 +1,224 @@ +import { For, Show, type Accessor, type Component, type JSX } from "solid-js" + +import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer" + +import DiffToolbar from "../components/DiffToolbar" +import SplitFilePanel from "../components/SplitFilePanel" +import type { DiffContextMode, DiffViewMode } from "../types" + +interface ChangesTabProps { + t: (key: string, vars?: Record) => string + + instanceId: string + activeSessionId: Accessor + activeSessionDiffs: Accessor + + selectedFile: Accessor + onSelectFile: (file: string, closeList: boolean) => void + + diffViewMode: Accessor + diffContextMode: Accessor + onViewModeChange: (mode: DiffViewMode) => void + onContextModeChange: (mode: DiffContextMode) => void + + listOpen: Accessor + onToggleList: () => void + splitWidth: Accessor + onResizeMouseDown: (event: MouseEvent) => void + onResizeTouchStart: (event: TouchEvent) => void + isPhoneLayout: Accessor +} + +const ChangesTab: Component = (props) => { + const renderContent = (): JSX.Element => { + const sessionId = props.activeSessionId() + if (!sessionId || sessionId === "info") { + return ( +
+ {props.t("instanceShell.sessionChanges.noSessionSelected")} +
+ ) + } + + const diffs = props.activeSessionDiffs() + if (diffs === undefined) { + return ( +
+ {props.t("instanceShell.sessionChanges.loading")} +
+ ) + } + + if (!Array.isArray(diffs) || diffs.length === 0) { + return ( +
+ {props.t("instanceShell.sessionChanges.empty")} +
+ ) + } + + const sorted = [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) + const totals = sorted.reduce( + (acc, item) => { + acc.additions += typeof item.additions === "number" ? item.additions : 0 + acc.deletions += typeof item.deletions === "number" ? item.deletions : 0 + return acc + }, + { additions: 0, deletions: 0 }, + ) + + const mostChanged = sorted.reduce((best, item) => { + const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0 + const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0 + const bestScore = bestAdd + bestDel + + const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0 + const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0 + const score = add + del + + if (score > bestScore) return item + if (score < bestScore) return best + return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best + }, sorted[0]) + + // Auto-select the most-changed file if none selected. + const currentSelected = props.selectedFile() + const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged + + const scopeKey = `${props.instanceId}:${sessionId}` + + const isBinaryDiff = (item: any) => { + const before = typeof item?.before === "string" ? item.before : "" + const after = typeof item?.after === "string" ? item.after : "" + if (before.length === 0 && after.length === 0) { + // OpenCode stores empty before/after for binaries. + return true + } + return false + } + + const renderViewer = () => ( +
+
+ +
+
+ + {props.t("instanceShell.filesShell.viewerEmpty")} +
+ } + > + {(file) => ( + + Binary file cannot be displayed +
+ } + > + + + )} + +
+
+ ) + + const renderListPanel = () => ( + + {(item) => ( +
{ + props.onSelectFile(item.file, props.isPhoneLayout()) + }} + > +
+
+ {item.file} +
+
+ +{item.additions} + -{item.deletions} +
+
+
+ )} +
+ ) + + const renderListOverlay = () => ( + + {(item) => ( +
{ + props.onSelectFile(item.file, true) + }} + title={item.file} + > +
+
+ {item.file} +
+
+ +{item.additions} + -{item.deletions} +
+
+
+ )} +
+ ) + + return ( + + + {selectedFileData?.file || ""} + + +
+ + +{totals.additions} + + + -{totals.deletions} + +
+ + } + list={{ panel: renderListPanel, overlay: renderListOverlay }} + viewer={renderViewer()} + listOpen={props.listOpen()} + onToggleList={props.onToggleList} + splitWidth={props.splitWidth()} + onResizeMouseDown={props.onResizeMouseDown} + onResizeTouchStart={props.onResizeTouchStart} + isPhoneLayout={props.isPhoneLayout()} + overlayAriaLabel="Changes" + /> + ) + } + + return <>{renderContent()} +} + +export default ChangesTab diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx new file mode 100644 index 00000000..dcba9267 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx @@ -0,0 +1,189 @@ +import { For, Show, type Accessor, type Component, type JSX } from "solid-js" +import type { FileNode } from "@opencode-ai/sdk/v2/client" + +import { RefreshCw } from "lucide-solid" + +import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer" + +import SplitFilePanel from "../components/SplitFilePanel" + +interface FilesTabProps { + t: (key: string, vars?: Record) => string + + browserPath: Accessor + browserEntries: Accessor + browserLoading: Accessor + browserError: Accessor + + browserSelectedPath: Accessor + browserSelectedContent: Accessor + browserSelectedLoading: Accessor + browserSelectedError: Accessor + + parentPath: Accessor + scopeKey: Accessor + + onLoadEntries: (path: string) => void + onOpenFile: (path: string) => void + onRefresh: () => void + + listOpen: Accessor + onToggleList: () => void + splitWidth: Accessor + onResizeMouseDown: (event: MouseEvent) => void + onResizeTouchStart: (event: TouchEvent) => void + isPhoneLayout: Accessor +} + +const FilesTab: Component = (props) => { + const renderContent = (): JSX.Element => { + if (props.browserLoading() && props.browserEntries() === null) { + return ( +
+ Loading files... +
+ ) + } + + const entries = props.browserEntries() || [] + const sorted = [...entries].sort((a, b) => { + const aDir = a.type === "directory" ? 0 : 1 + const bDir = b.type === "directory" ? 0 : 1 + if (aDir !== bDir) return aDir - bDir + return String(a.name || "").localeCompare(String(b.name || "")) + }) + + const parent = props.parentPath() + + const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath() + + const renderViewer = () => ( +
+
+ + Select a file to preview +
+ } + > + {(payload) => ( + + )} + + } + > + {(err) => ( +
+ {err()} +
+ )} + + } + > +
+ Loading… +
+ +
+
+ ) + + const renderList = () => ( + <> + + {(p) => ( +
props.onLoadEntries(p())}> +
+
+ .. +
+
+
+ )} +
+ + + {(item) => ( +
{ + if (item.type === "directory") { + props.onLoadEntries(item.path) + return + } + props.onOpenFile(item.path) + }} + title={item.path} + > +
+
+ {item.name} +
+
+ {item.type} +
+
+
+ )} +
+ + ) + + return ( + +
+ + + {headerDisplayedPath()} + + + + Loading… + + {(err) => {err()}} +
+ + + + } + list={{ panel: renderList, overlay: renderList }} + viewer={renderViewer()} + listOpen={props.listOpen()} + onToggleList={props.onToggleList} + splitWidth={props.splitWidth()} + onResizeMouseDown={props.onResizeMouseDown} + onResizeTouchStart={props.onResizeTouchStart} + isPhoneLayout={props.isPhoneLayout()} + overlayAriaLabel="Files" + /> + ) + } + + return <>{renderContent()} +} + +export default FilesTab diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx new file mode 100644 index 00000000..48811f11 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx @@ -0,0 +1,262 @@ +import { For, Show, type Accessor, type Component, type JSX } from "solid-js" +import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client" + +import { RefreshCw } from "lucide-solid" + +import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer" + +import DiffToolbar from "../components/DiffToolbar" +import SplitFilePanel from "../components/SplitFilePanel" +import type { DiffContextMode, DiffViewMode } from "../types" + +interface GitChangesTabProps { + t: (key: string, vars?: Record) => string + + activeSessionId: Accessor + + entries: Accessor + statusLoading: Accessor + statusError: Accessor + + selectedPath: Accessor + selectedLoading: Accessor + selectedError: Accessor + selectedBefore: Accessor + selectedAfter: Accessor + mostChangedPath: Accessor + + scopeKey: Accessor + + diffViewMode: Accessor + diffContextMode: Accessor + onViewModeChange: (mode: DiffViewMode) => void + onContextModeChange: (mode: DiffContextMode) => void + + onOpenFile: (path: string) => void + onRefresh: () => void + + listOpen: Accessor + onToggleList: () => void + splitWidth: Accessor + onResizeMouseDown: (event: MouseEvent) => void + onResizeTouchStart: (event: TouchEvent) => void + isPhoneLayout: Accessor +} + +const GitChangesTab: Component = (props) => { + const renderContent = (): JSX.Element => { + const sessionId = props.activeSessionId() + if (!sessionId || sessionId === "info") { + return ( +
+ Select a session to view changes. +
+ ) + } + + const entries = props.entries() + if (entries === null) { + return ( +
+ Loading git changes… +
+ ) + } + + const nonDeleted = entries.filter((item) => item && item.status !== "deleted") + if (nonDeleted.length === 0) { + return ( +
+ No git changes yet. +
+ ) + } + + const sorted = [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || ""))) + const totals = sorted.reduce( + (acc, item) => { + acc.additions += typeof item.added === "number" ? item.added : 0 + acc.deletions += typeof item.removed === "number" ? item.removed : 0 + return acc + }, + { additions: 0, deletions: 0 }, + ) + + const selectedPath = props.selectedPath() + const fallbackPath = props.mostChangedPath() + const selectedEntry = + sorted.find((item) => item.path === selectedPath) || + (fallbackPath ? sorted.find((item) => item.path === fallbackPath) : null) + + const renderViewer = () => ( +
+
+ +
+
+ + No file selected. +
+ } + > + {(file) => ( + + )} + + } + > + {(err) => ( +
+ {err()} +
+ )} + + } + > +
+ Loading… +
+ +
+
+ ) + + const renderListPanel = () => ( + + {(item) => ( +
{ + props.onOpenFile(item.path) + }} + > +
+
+ {item.path} +
+
+ + deleted + + + <> + +{item.added} + -{item.removed} + + +
+
+
+ )} +
+ ) + + const renderListOverlay = () => ( + + {(item) => ( +
props.onOpenFile(item.path)} + title={item.path} + > +
+
+ {item.path} +
+
+ + deleted + + + <> + +{item.added} + -{item.removed} + + +
+
+
+ )} +
+ ) + + return ( + + + {selectedEntry?.path || ""} + + +
+ + +{totals.additions} + + + -{totals.deletions} + + {(err) => {err()}} +
+ + + + } + list={{ panel: renderListPanel, overlay: renderListOverlay }} + viewer={renderViewer()} + listOpen={props.listOpen()} + onToggleList={props.onToggleList} + splitWidth={props.splitWidth()} + onResizeMouseDown={props.onResizeMouseDown} + onResizeTouchStart={props.onResizeTouchStart} + isPhoneLayout={props.isPhoneLayout()} + overlayAriaLabel="Git Changes" + /> + ) + } + + return <>{renderContent()} +} + +export default GitChangesTab diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx new file mode 100644 index 00000000..6786b455 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx @@ -0,0 +1,294 @@ +import { For, Show, type Accessor, type Component } from "solid-js" +import type { ToolState } from "@opencode-ai/sdk" +import { Accordion } from "@kobalte/core" + +import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid" + +import type { Instance } from "../../../../../types/instance" +import type { BackgroundProcess } from "../../../../../../../server/src/api-types" +import type { Session } from "../../../../../types/session" + +import ContextUsagePanel from "../../../../session/context-usage-panel" +import { TodoListView } from "../../../../tool-call/renderers/todo" +import InstanceServiceStatus from "../../../../instance-service-status" + +interface StatusTabProps { + t: (key: string, vars?: Record) => string + + instanceId: string + instance: Instance + + activeSessionId: Accessor + activeSession: Accessor + activeSessionDiffs: Accessor + + latestTodoState: Accessor + + backgroundProcessList: Accessor + onOpenBackgroundOutput: (process: BackgroundProcess) => void + onStopBackgroundProcess: (processId: string) => Promise | void + onTerminateBackgroundProcess: (processId: string) => Promise | void + + expandedItems: Accessor + onExpandedItemsChange: (values: string[]) => void + + onOpenChangesTab: (file?: string) => void +} + +const StatusTab: Component = (props) => { + const isSectionExpanded = (id: string) => props.expandedItems().includes(id) + + const renderStatusSessionChanges = () => { + const sessionId = props.activeSessionId() + if (!sessionId || sessionId === "info") { + return ( +
+ {props.t("instanceShell.sessionChanges.noSessionSelected")} +
+ ) + } + + const diffs = props.activeSessionDiffs() + if (diffs === undefined) { + return ( +
+ {props.t("instanceShell.sessionChanges.loading")} +
+ ) + } + + if (!Array.isArray(diffs) || diffs.length === 0) { + return ( +
+ {props.t("instanceShell.sessionChanges.empty")} +
+ ) + } + + const sorted = [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) + const totals = sorted.reduce( + (acc, item) => { + acc.additions += typeof item.additions === "number" ? item.additions : 0 + acc.deletions += typeof item.deletions === "number" ? item.deletions : 0 + return acc + }, + { additions: 0, deletions: 0 }, + ) + + return ( +
+
+ {props.t("instanceShell.sessionChanges.filesChanged", { count: sorted.length })} + + {`+${totals.additions}`} + {`-${totals.deletions}`} + +
+ +
+
+ + {(item) => ( + + )} + +
+
+
+ ) + } + + const renderPlanSectionContent = () => { + const sessionId = props.activeSessionId() + if (!sessionId || sessionId === "info") { + return ( +
+ {props.t("instanceShell.plan.noSessionSelected")} +
+ ) + } + const todoState = props.latestTodoState() + if (!todoState) { + return ( +
+ {props.t("instanceShell.plan.empty")} +
+ ) + } + return + } + + const renderBackgroundProcesses = () => { + const processes = props.backgroundProcessList() + if (processes.length === 0) { + return ( +
+ {props.t("instanceShell.backgroundProcesses.empty")} +
+ ) + } + + return ( +
+ + {(process) => ( +
+
+ {process.title} +
+ {props.t("instanceShell.backgroundProcesses.status", { status: process.status })} + + + {props.t("instanceShell.backgroundProcesses.output", { + sizeKb: Math.round((process.outputSizeBytes ?? 0) / 1024), + })} + + +
+
+
+ + + +
+
+ )} +
+
+ ) + } + + const statusSections = [ + { + id: "session-changes", + labelKey: "instanceShell.rightPanel.sections.sessionChanges", + render: renderStatusSessionChanges, + }, + { + id: "plan", + labelKey: "instanceShell.rightPanel.sections.plan", + render: renderPlanSectionContent, + }, + { + id: "background-processes", + labelKey: "instanceShell.rightPanel.sections.backgroundProcesses", + render: renderBackgroundProcesses, + }, + { + id: "mcp", + labelKey: "instanceShell.rightPanel.sections.mcp", + render: () => ( + + ), + }, + { + id: "lsp", + labelKey: "instanceShell.rightPanel.sections.lsp", + render: () => ( + + ), + }, + { + id: "plugins", + labelKey: "instanceShell.rightPanel.sections.plugins", + render: () => ( + + ), + }, + ] + + return ( +
+ + {(activeSession) => ( + + )} + + + + + {(section) => ( + + + + {props.t(section.labelKey)} + + + + {section.render()} + + )} + + +
+ ) +} + +export default StatusTab diff --git a/packages/ui/src/components/instance/shell/right-panel/types.ts b/packages/ui/src/components/instance/shell/right-panel/types.ts new file mode 100644 index 00000000..e651e528 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/types.ts @@ -0,0 +1,5 @@ +export type RightPanelTab = "changes" | "git-changes" | "files" | "status" + +export type DiffViewMode = "split" | "unified" + +export type DiffContextMode = "expanded" | "collapsed" diff --git a/packages/ui/src/components/instance/shell/storage.ts b/packages/ui/src/components/instance/shell/storage.ts new file mode 100644 index 00000000..5f1d7421 --- /dev/null +++ b/packages/ui/src/components/instance/shell/storage.ts @@ -0,0 +1,92 @@ +export const DEFAULT_SESSION_SIDEBAR_WIDTH = 340 +export const MIN_SESSION_SIDEBAR_WIDTH = 220 +export const MAX_SESSION_SIDEBAR_WIDTH = 400 + +export const RIGHT_DRAWER_WIDTH = 260 +export const MIN_RIGHT_DRAWER_WIDTH = 200 +export const MAX_RIGHT_DRAWER_WIDTH = 1200 + +export const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8" +export const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1" +export const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1" +export const RIGHT_PIN_STORAGE_KEY = "opencode-session-right-drawer-pinned-v1" +export const RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v2" +export const LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v1" +export const RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-changes-split-width-v1" +export const RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-files-split-width-v1" +export const RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-git-changes-split-width-v1" +export const RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-changes-list-open-nonphone-v1" +export const RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-changes-list-open-phone-v1" +export const RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-files-list-open-nonphone-v1" +export const RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-files-list-open-phone-v1" +export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-list-open-nonphone-v1" +export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1" +export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1" +export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1" + +export const clampWidth = (value: number) => + Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value)) + +export const clampRightWidth = (value: number) => { + const windowMax = typeof window !== "undefined" ? Math.floor(window.innerWidth * 0.7) : MAX_RIGHT_DRAWER_WIDTH + const max = Math.max(MIN_RIGHT_DRAWER_WIDTH, windowMax) + return Math.min(max, Math.max(MIN_RIGHT_DRAWER_WIDTH, value)) +} + +const getPinStorageKey = (side: "left" | "right") => (side === "left" ? LEFT_PIN_STORAGE_KEY : RIGHT_PIN_STORAGE_KEY) + +export function readStoredPinState(side: "left" | "right", defaultValue: boolean) { + if (typeof window === "undefined") return defaultValue + const stored = window.localStorage.getItem(getPinStorageKey(side)) + if (stored === "true") return true + if (stored === "false") return false + return defaultValue +} + +export function persistPinState(side: "left" | "right", value: boolean) { + if (typeof window === "undefined") return + window.localStorage.setItem(getPinStorageKey(side), value ? "true" : "false") +} + +export function readStoredRightPanelTab( + defaultValue: "changes" | "git-changes" | "files" | "status", +): "changes" | "git-changes" | "files" | "status" { + if (typeof window === "undefined") return defaultValue + + const stored = window.localStorage.getItem(RIGHT_PANEL_TAB_STORAGE_KEY) + if (stored === "status") return "status" + if (stored === "changes") return "changes" + if (stored === "git-changes") return "git-changes" + if (stored === "files") return "files" + + // Migrate from v1 (where the stored values were the internal tab ids). + const legacy = window.localStorage.getItem(LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY) + if (legacy === "status") return "status" + if (legacy === "browser") return "files" + if (legacy === "files") return "changes" + + return defaultValue +} + +export function readStoredPanelWidth(key: string, fallback: number) { + if (typeof window === "undefined") return fallback + const stored = window.localStorage.getItem(key) + if (!stored) return fallback + const parsed = Number.parseInt(stored, 10) + return Number.isFinite(parsed) ? parsed : fallback +} + +export function readStoredBool(key: string): boolean | null { + if (typeof window === "undefined") return null + const stored = window.localStorage.getItem(key) + if (stored === "true") return true + if (stored === "false") return false + return null +} + +export function readStoredEnum(key: string, allowed: readonly T[]): T | null { + if (typeof window === "undefined") return null + const stored = window.localStorage.getItem(key) + if (!stored) return null + return (allowed as readonly string[]).includes(stored) ? (stored as T) : null +} diff --git a/packages/ui/src/components/instance/shell/types.ts b/packages/ui/src/components/instance/shell/types.ts new file mode 100644 index 00000000..9ca38695 --- /dev/null +++ b/packages/ui/src/components/instance/shell/types.ts @@ -0,0 +1,3 @@ +export type LayoutMode = "desktop" | "tablet" | "phone" + +export type DrawerViewState = "pinned" | "floating-open" | "floating-closed" diff --git a/packages/ui/src/components/instance/shell/useDrawerChrome.ts b/packages/ui/src/components/instance/shell/useDrawerChrome.ts new file mode 100644 index 00000000..bccc8444 --- /dev/null +++ b/packages/ui/src/components/instance/shell/useDrawerChrome.ts @@ -0,0 +1,260 @@ +import { + batch, + createComponent, + createEffect, + createMemo, + createSignal, + onCleanup, + onMount, + type Accessor, + type JSX, + type Setter, +} from "solid-js" +import MenuIcon from "@suid/icons-material/Menu" + +import type { TranslateParams } from "../../../lib/i18n" + +import type { DrawerViewState, LayoutMode } from "./types" +import { persistPinState, readStoredPinState } from "./storage" + +export interface UseDrawerChromeOptions { + t: (key: string, params?: TranslateParams) => string + layoutMode: Accessor + leftPinningSupported: Accessor + rightPinningSupported: Accessor + leftDrawerContentEl: Accessor + rightDrawerContentEl: Accessor + leftToggleButtonEl: Accessor + rightToggleButtonEl: Accessor + measureDrawerHost?: () => void +} + +export interface DrawerChromeApi { + leftPinned: Accessor + leftOpen: Accessor + rightPinned: Accessor + rightOpen: Accessor + setLeftOpen: Setter + setRightOpen: Setter + leftDrawerState: Accessor + rightDrawerState: Accessor + pinLeft: () => void + unpinLeft: () => void + pinRight: () => void + unpinRight: () => void + closeLeft: () => void + closeRight: () => void + leftAppBarButtonLabel: Accessor + rightAppBarButtonLabel: Accessor + leftAppBarButtonIcon: Accessor + rightAppBarButtonIcon: Accessor + handleLeftAppBarButtonClick: () => void + handleRightAppBarButtonClick: () => void +} + +export function useDrawerChrome(options: UseDrawerChromeOptions): DrawerChromeApi { + const [leftPinned, setLeftPinned] = createSignal(true) + const [leftOpen, setLeftOpen] = createSignal(true) + const [rightPinned, setRightPinned] = createSignal(true) + const [rightOpen, setRightOpen] = createSignal(true) + + const measureDrawerHost = () => options.measureDrawerHost?.() + + const focusTarget = (element: HTMLElement | null) => { + if (!element) return + requestAnimationFrame(() => { + element.focus() + }) + } + + const blurIfInside = (element: HTMLElement | null) => { + if (typeof document === "undefined" || !element) return + const active = document.activeElement as HTMLElement | null + if (active && element.contains(active)) { + active.blur() + } + } + + const persistPinIfSupported = (side: "left" | "right", value: boolean) => { + if (side === "left" && !options.leftPinningSupported()) return + if (side === "right" && !options.rightPinningSupported()) return + persistPinState(side, value) + } + + createEffect(() => { + switch (options.layoutMode()) { + case "desktop": { + const leftSaved = readStoredPinState("left", true) + const rightSaved = readStoredPinState("right", true) + setLeftPinned(leftSaved) + setLeftOpen(leftSaved) + setRightPinned(rightSaved) + setRightOpen(rightSaved) + break + } + case "tablet": { + setLeftPinned(true) + setLeftOpen(true) + setRightPinned(false) + setRightOpen(false) + break + } + default: + setLeftPinned(false) + setLeftOpen(false) + setRightPinned(false) + setRightOpen(false) + break + } + }) + + const leftDrawerState = createMemo(() => { + if (leftPinned()) return "pinned" + return leftOpen() ? "floating-open" : "floating-closed" + }) + + const rightDrawerState = createMemo(() => { + if (rightPinned()) return "pinned" + return rightOpen() ? "floating-open" : "floating-closed" + }) + + const leftAppBarButtonLabel = () => { + const state = leftDrawerState() + if (state === "pinned") return options.t("instanceShell.leftDrawer.toggle.pinned") + return options.t("instanceShell.leftDrawer.toggle.open") + } + + const rightAppBarButtonLabel = () => { + const state = rightDrawerState() + if (state === "pinned") return options.t("instanceShell.rightDrawer.toggle.pinned") + return options.t("instanceShell.rightDrawer.toggle.open") + } + + const leftAppBarButtonIcon = () => { + return createComponent(MenuIcon, { fontSize: "small" }) + } + + const rightAppBarButtonIcon = () => { + return createComponent(MenuIcon, { fontSize: "small", sx: { transform: "scaleX(-1)" } }) + } + + const pinLeft = () => { + blurIfInside(options.leftDrawerContentEl()) + batch(() => { + setLeftPinned(true) + setLeftOpen(true) + }) + persistPinIfSupported("left", true) + measureDrawerHost() + } + + const unpinLeft = () => { + blurIfInside(options.leftDrawerContentEl()) + batch(() => { + setLeftPinned(false) + setLeftOpen(true) + }) + persistPinIfSupported("left", false) + measureDrawerHost() + } + + const pinRight = () => { + blurIfInside(options.rightDrawerContentEl()) + batch(() => { + setRightPinned(true) + setRightOpen(true) + }) + persistPinIfSupported("right", true) + measureDrawerHost() + } + + const unpinRight = () => { + blurIfInside(options.rightDrawerContentEl()) + batch(() => { + setRightPinned(false) + setRightOpen(true) + }) + persistPinIfSupported("right", false) + measureDrawerHost() + } + + const handleLeftAppBarButtonClick = () => { + const state = leftDrawerState() + if (state !== "floating-closed") return + setLeftOpen(true) + measureDrawerHost() + } + + const handleRightAppBarButtonClick = () => { + const state = rightDrawerState() + if (state !== "floating-closed") return + setRightOpen(true) + measureDrawerHost() + } + + const closeLeft = () => { + if (leftDrawerState() === "pinned") return + blurIfInside(options.leftDrawerContentEl()) + setLeftOpen(false) + focusTarget(options.leftToggleButtonEl()) + } + + const closeRight = () => { + if (rightDrawerState() === "pinned") return + blurIfInside(options.rightDrawerContentEl()) + setRightOpen(false) + focusTarget(options.rightToggleButtonEl()) + } + + const closeFloatingDrawersIfAny = () => { + let handled = false + if (!leftPinned() && leftOpen()) { + setLeftOpen(false) + blurIfInside(options.leftDrawerContentEl()) + focusTarget(options.leftToggleButtonEl()) + handled = true + } + if (!rightPinned() && rightOpen()) { + setRightOpen(false) + blurIfInside(options.rightDrawerContentEl()) + focusTarget(options.rightToggleButtonEl()) + handled = true + } + return handled + } + + onMount(() => { + if (typeof window === "undefined") return + const handleEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return + if (!closeFloatingDrawersIfAny()) return + event.preventDefault() + event.stopPropagation() + } + window.addEventListener("keydown", handleEscape, true) + onCleanup(() => window.removeEventListener("keydown", handleEscape, true)) + }) + + return { + leftPinned, + leftOpen, + rightPinned, + rightOpen, + setLeftOpen, + setRightOpen, + leftDrawerState, + rightDrawerState, + pinLeft, + unpinLeft, + pinRight, + unpinRight, + closeLeft, + closeRight, + leftAppBarButtonLabel, + rightAppBarButtonLabel, + leftAppBarButtonIcon, + rightAppBarButtonIcon, + handleLeftAppBarButtonClick, + handleRightAppBarButtonClick, + } +} diff --git a/packages/ui/src/components/instance/shell/useDrawerHostMeasure.ts b/packages/ui/src/components/instance/shell/useDrawerHostMeasure.ts new file mode 100644 index 00000000..5675424e --- /dev/null +++ b/packages/ui/src/components/instance/shell/useDrawerHostMeasure.ts @@ -0,0 +1,65 @@ +import { createEffect, createSignal, type Accessor } from "solid-js" + +type DrawerHostMeasure = { + setDrawerHost: (element: HTMLElement) => void + drawerContainer: () => HTMLElement | undefined + measureDrawerHost: () => void + floatingTopPx: () => string + floatingHeight: () => string +} + +export function useDrawerHostMeasure(tabBarOffset: Accessor): DrawerHostMeasure { + const [drawerHost, setDrawerHost] = createSignal(null) + const [floatingDrawerTop, setFloatingDrawerTop] = createSignal(0) + const [floatingDrawerHeight, setFloatingDrawerHeight] = createSignal(0) + + const storeDrawerHost = (element: HTMLElement) => { + setDrawerHost(element) + } + + const measureDrawerHost = () => { + if (typeof window === "undefined") return + const host = drawerHost() + if (!host) return + const rect = host.getBoundingClientRect() + setFloatingDrawerTop(rect.top) + setFloatingDrawerHeight(Math.max(0, rect.height)) + } + + createEffect(() => { + tabBarOffset() + if (typeof window === "undefined") return + requestAnimationFrame(() => measureDrawerHost()) + }) + + const drawerContainer = () => { + const host = drawerHost() + if (host) return host + if (typeof document !== "undefined") { + return document.body + } + return undefined + } + + const fallbackDrawerTop = () => tabBarOffset() + const floatingTop = () => { + const measured = floatingDrawerTop() + if (measured > 0) return measured + return fallbackDrawerTop() + } + + const floatingTopPx = () => `${floatingTop()}px` + const floatingHeight = () => { + const measured = floatingDrawerHeight() + if (measured > 0) return `${measured}px` + return `calc(100% - ${floatingTop()}px)` + } + + return { + setDrawerHost: storeDrawerHost, + drawerContainer, + measureDrawerHost, + floatingTopPx, + floatingHeight, + } +} diff --git a/packages/ui/src/components/instance/shell/useDrawerResize.ts b/packages/ui/src/components/instance/shell/useDrawerResize.ts new file mode 100644 index 00000000..d3a4f982 --- /dev/null +++ b/packages/ui/src/components/instance/shell/useDrawerResize.ts @@ -0,0 +1,113 @@ +import { createSignal, onCleanup, type Accessor, type Setter } from "solid-js" + +import { useGlobalPointerDrag } from "./useGlobalPointerDrag" + +type DrawerResizeSide = "left" | "right" + +type DrawerResizeOptions = { + sessionSidebarWidth: Accessor + rightDrawerWidth: Accessor + setSessionSidebarWidth: Setter + setRightDrawerWidth: Setter + clampLeft: (width: number) => number + clampRight: (width: number) => number + measureDrawerHost: () => void +} + +type DrawerResizeApi = { + handleDrawerResizeMouseDown: (side: DrawerResizeSide) => (event: MouseEvent) => void + handleDrawerResizeTouchStart: (side: DrawerResizeSide) => (event: TouchEvent) => void +} + +export function useDrawerResize(options: DrawerResizeOptions): DrawerResizeApi { + const [activeResizeSide, setActiveResizeSide] = createSignal(null) + const [resizeStartX, setResizeStartX] = createSignal(0) + const [resizeStartWidth, setResizeStartWidth] = createSignal(0) + + const scheduleDrawerMeasure = () => { + if (typeof window === "undefined") { + options.measureDrawerHost() + return + } + requestAnimationFrame(() => options.measureDrawerHost()) + } + + const applyDrawerWidth = (side: DrawerResizeSide, width: number) => { + if (side === "left") { + options.setSessionSidebarWidth(width) + } else { + options.setRightDrawerWidth(width) + } + scheduleDrawerMeasure() + } + + const handleDrawerPointerMove = (clientX: number) => { + const side = activeResizeSide() + if (!side) return + const startWidth = resizeStartWidth() + const clamp = side === "left" ? options.clampLeft : options.clampRight + const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX + const nextWidth = clamp(startWidth + delta) + applyDrawerWidth(side, nextWidth) + } + + function drawerMouseMove(event: MouseEvent) { + event.preventDefault() + handleDrawerPointerMove(event.clientX) + } + + function drawerMouseUp() { + stopDrawerResize() + } + + function drawerTouchMove(event: TouchEvent) { + const touch = event.touches[0] + if (!touch) return + event.preventDefault() + handleDrawerPointerMove(touch.clientX) + } + + function drawerTouchEnd() { + stopDrawerResize() + } + + const drawerPointerDrag = useGlobalPointerDrag({ + onMouseMove: drawerMouseMove, + onMouseUp: drawerMouseUp, + onTouchMove: drawerTouchMove, + onTouchEnd: drawerTouchEnd, + }) + + function stopDrawerResize() { + setActiveResizeSide(null) + drawerPointerDrag.stop() + } + + const startDrawerResize = (side: DrawerResizeSide, clientX: number) => { + setActiveResizeSide(side) + setResizeStartX(clientX) + setResizeStartWidth(side === "left" ? options.sessionSidebarWidth() : options.rightDrawerWidth()) + drawerPointerDrag.start() + } + + const handleDrawerResizeMouseDown = (side: DrawerResizeSide) => (event: MouseEvent) => { + event.preventDefault() + startDrawerResize(side, event.clientX) + } + + const handleDrawerResizeTouchStart = (side: DrawerResizeSide) => (event: TouchEvent) => { + const touch = event.touches[0] + if (!touch) return + event.preventDefault() + startDrawerResize(side, touch.clientX) + } + + onCleanup(() => { + stopDrawerResize() + }) + + return { + handleDrawerResizeMouseDown, + handleDrawerResizeTouchStart, + } +} diff --git a/packages/ui/src/components/instance/shell/useGlobalPointerDrag.ts b/packages/ui/src/components/instance/shell/useGlobalPointerDrag.ts new file mode 100644 index 00000000..380d4133 --- /dev/null +++ b/packages/ui/src/components/instance/shell/useGlobalPointerDrag.ts @@ -0,0 +1,29 @@ +type GlobalPointerDragHandlers = { + onMouseMove: (event: MouseEvent) => void + onMouseUp: (event: MouseEvent) => void + onTouchMove: (event: TouchEvent) => void + onTouchEnd: (event: TouchEvent) => void +} + +type GlobalPointerDrag = { + start: () => void + stop: () => void +} + +export function useGlobalPointerDrag(handlers: GlobalPointerDragHandlers): GlobalPointerDrag { + const start = () => { + document.addEventListener("mousemove", handlers.onMouseMove) + document.addEventListener("mouseup", handlers.onMouseUp) + document.addEventListener("touchmove", handlers.onTouchMove, { passive: false }) + document.addEventListener("touchend", handlers.onTouchEnd) + } + + const stop = () => { + document.removeEventListener("mousemove", handlers.onMouseMove) + document.removeEventListener("mouseup", handlers.onMouseUp) + document.removeEventListener("touchmove", handlers.onTouchMove) + document.removeEventListener("touchend", handlers.onTouchEnd) + } + + return { start, stop } +} diff --git a/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts b/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts new file mode 100644 index 00000000..faee193a --- /dev/null +++ b/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts @@ -0,0 +1,173 @@ +import { batch, createMemo, type Accessor } from "solid-js" +import type { ToolState } from "@opencode-ai/sdk" +import type { Session } from "../../../types/session" +import { + activeParentSessionId, + activeSessionId as activeSessionMap, + getSessionFamily, + getSessionInfo, + getSessionThreads, + sessions, + setActiveParentSession, + setActiveSession, +} from "../../../stores/sessions" +import { messageStoreBus } from "../../../stores/message-v2/bus" +import { getBackgroundProcesses } from "../../../stores/background-processes" +import type { LatestTodoSnapshot, SessionUsageState } from "../../../stores/message-v2/types" + +type InstanceSessionContextOptions = { + instanceId: Accessor +} + +type InstanceSessionContextState = { + // Session collections and selections + allInstanceSessions: Accessor> + sessionThreads: Accessor> + activeSessions: Accessor> + activeSessionIdForInstance: Accessor + parentSessionIdForInstance: Accessor + activeSessionForInstance: Accessor + activeSessionDiffs: Accessor + + // Usage / info summaries + activeSessionUsage: Accessor + activeSessionInfoDetails: Accessor | null> + tokenStats: Accessor<{ used: number; avail: number | null }> + + // Todo state + latestTodoSnapshot: Accessor + latestTodoState: Accessor + + // Background processes + backgroundProcessList: Accessor> + + // Controller + handleSessionSelect: (sessionId: string) => void +} + +type SessionFamilyMember = ReturnType[number] + +export function useInstanceSessionContext(options: InstanceSessionContextOptions): InstanceSessionContextState { + const messageStore = createMemo(() => messageStoreBus.getOrCreate(options.instanceId())) + + const allInstanceSessions = createMemo>(() => { + return sessions().get(options.instanceId()) ?? new Map() + }) + + const sessionThreads = createMemo(() => getSessionThreads(options.instanceId())) + + const activeSessions = createMemo(() => { + const parentId = activeParentSessionId().get(options.instanceId()) + if (!parentId) return new Map[number]>() + const sessionFamily = getSessionFamily(options.instanceId(), parentId) + return new Map(sessionFamily.map((s) => [s.id, s])) + }) + + const activeSessionIdForInstance = createMemo(() => { + return activeSessionMap().get(options.instanceId()) || null + }) + + const parentSessionIdForInstance = createMemo(() => { + return activeParentSessionId().get(options.instanceId()) || null + }) + + const activeSessionForInstance = createMemo(() => { + const sessionId = activeSessionIdForInstance() + if (!sessionId || sessionId === "info") return null + return activeSessions().get(sessionId) ?? null + }) + + const activeSessionDiffs = createMemo(() => { + const session = activeSessionForInstance() + return session?.diff + }) + + const activeSessionUsage = createMemo(() => { + const sessionId = activeSessionIdForInstance() + if (!sessionId) return null + const store = messageStore() + return store?.getSessionUsage(sessionId) ?? null + }) + + const activeSessionInfoDetails = createMemo(() => { + const sessionId = activeSessionIdForInstance() + if (!sessionId) return null + return getSessionInfo(options.instanceId(), sessionId) ?? null + }) + + const tokenStats = createMemo(() => { + const usage = activeSessionUsage() + const info = activeSessionInfoDetails() + return { + used: usage?.actualUsageTokens ?? info?.actualUsageTokens ?? 0, + avail: info?.contextAvailableTokens ?? null, + } + }) + + const latestTodoSnapshot = createMemo(() => { + const sessionId = activeSessionIdForInstance() + if (!sessionId || sessionId === "info") return null + const store = messageStore() + if (!store) return null + const snapshot = store.state.latestTodos[sessionId] + return snapshot ?? null + }) + + const latestTodoState = createMemo(() => { + const snapshot = latestTodoSnapshot() + if (!snapshot) return null + const store = messageStore() + if (!store) return null + const message = store.getMessage(snapshot.messageId) + if (!message) return null + const partRecord = message.parts?.[snapshot.partId] + const part = partRecord?.data as { type?: string; tool?: string; state?: ToolState } + if (!part || part.type !== "tool" || part.tool !== "todowrite") return null + const state = part.state + if (!state || state.status !== "completed") return null + return state + }) + + const backgroundProcessList = createMemo(() => getBackgroundProcesses(options.instanceId())) + + const handleSessionSelect = (sessionId: string) => { + const instanceId = options.instanceId() + if (sessionId === "info") { + setActiveSession(instanceId, sessionId) + return + } + + const session = allInstanceSessions().get(sessionId) + if (!session) return + + if (session.parentId === null) { + setActiveParentSession(instanceId, sessionId) + return + } + + const parentId = session.parentId + if (!parentId) return + + batch(() => { + setActiveParentSession(instanceId, parentId) + setActiveSession(instanceId, sessionId) + }) + } + + return { + allInstanceSessions, + sessionThreads, + activeSessions, + activeSessionIdForInstance, + parentSessionIdForInstance, + activeSessionForInstance, + activeSessionDiffs, + activeSessionUsage, + activeSessionInfoDetails, + tokenStats, + latestTodoSnapshot, + latestTodoState, + backgroundProcessList, + handleSessionSelect, + } +} diff --git a/packages/ui/src/components/instance/shell/useSessionCache.ts b/packages/ui/src/components/instance/shell/useSessionCache.ts new file mode 100644 index 00000000..0e3a3476 --- /dev/null +++ b/packages/ui/src/components/instance/shell/useSessionCache.ts @@ -0,0 +1,99 @@ +import { createEffect, createSignal, type Accessor } from "solid-js" +import { messageStoreBus } from "../../../stores/message-v2/bus" +import { clearSessionRenderCache } from "../../message-block" +import { getLogger } from "../../../lib/logger" + +const log = getLogger("session") + +const SESSION_CACHE_LIMIT = 5 + +type SessionCacheOptions = { + instanceId: Accessor + instanceSessions: Accessor> + activeSessionId: Accessor +} + +type SessionCacheState = { + cachedSessionIds: Accessor +} + +export function useSessionCache(options: SessionCacheOptions): SessionCacheState { + const [cachedSessionIds, setCachedSessionIds] = createSignal([]) + const [pendingEvictions, setPendingEvictions] = createSignal([]) + + const evictSession = (sessionId: string) => { + if (!sessionId) return + const instanceId = options.instanceId() + log.info("Evicting cached session", { instanceId, sessionId }) + const store = messageStoreBus.getInstance(instanceId) + store?.clearSession(sessionId) + clearSessionRenderCache(instanceId, sessionId) + } + + const scheduleEvictions = (ids: string[]) => { + if (!ids.length) return + setPendingEvictions((current) => { + const existing = new Set(current) + const next = [...current] + ids.forEach((id) => { + if (!existing.has(id)) { + next.push(id) + existing.add(id) + } + }) + return next + }) + } + + createEffect(() => { + const pending = pendingEvictions() + if (!pending.length) return + const cached = new Set(cachedSessionIds()) + const remaining: string[] = [] + pending.forEach((id) => { + if (cached.has(id)) { + remaining.push(id) + } else { + evictSession(id) + } + }) + if (remaining.length !== pending.length) { + setPendingEvictions(remaining) + } + }) + + createEffect(() => { + const instanceSessions = options.instanceSessions() + const activeId = options.activeSessionId() + + setCachedSessionIds((current) => { + const next = current.filter((id) => id !== "info" && instanceSessions.has(id)) + + const touch = (id: string | null) => { + if (!id || id === "info") return + if (!instanceSessions.has(id)) return + + const index = next.indexOf(id) + if (index !== -1) { + next.splice(index, 1) + } + next.unshift(id) + } + + touch(activeId) + + const trimmed = next.length > SESSION_CACHE_LIMIT ? next.slice(0, SESSION_CACHE_LIMIT) : next + + const trimmedSet = new Set(trimmed) + const removed = current.filter((id) => !trimmedSet.has(id)) + if (removed.length) { + scheduleEvictions(removed) + } + return trimmed + }) + }) + + return { + cachedSessionIds, + } +} diff --git a/packages/ui/src/components/instance/shell/useSessionSidebarRequests.ts b/packages/ui/src/components/instance/shell/useSessionSidebarRequests.ts new file mode 100644 index 00000000..3e0a5953 --- /dev/null +++ b/packages/ui/src/components/instance/shell/useSessionSidebarRequests.ts @@ -0,0 +1,109 @@ +import { createEffect, createSignal, onCleanup, onMount, type Accessor } from "solid-js" +import { + SESSION_SIDEBAR_EVENT, + type SessionSidebarRequestAction, + type SessionSidebarRequestDetail, +} from "../../../lib/session-sidebar-events" + +interface PendingSidebarAction { + action: SessionSidebarRequestAction + id: number +} + +interface UseSessionSidebarRequestsOptions { + instanceId: Accessor + sidebarContentEl: Accessor + leftPinned: Accessor + leftOpen: Accessor + setLeftOpen: (next: boolean) => void + measureDrawerHost: () => void +} + +export function useSessionSidebarRequests(options: UseSessionSidebarRequestsOptions) { + let sidebarActionId = 0 + const [pendingSidebarAction, setPendingSidebarAction] = createSignal(null) + + const triggerKeyboardEvent = (target: HTMLElement, options: { key: string; code: string; keyCode: number }) => { + target.dispatchEvent( + new KeyboardEvent("keydown", { + key: options.key, + code: options.code, + keyCode: options.keyCode, + which: options.keyCode, + bubbles: true, + cancelable: true, + }), + ) + } + + const focusAgentSelectorControl = () => { + const agentTrigger = options.sidebarContentEl()?.querySelector("[data-agent-selector]") as HTMLElement | null + if (!agentTrigger) return false + agentTrigger.focus() + setTimeout(() => triggerKeyboardEvent(agentTrigger, { key: "Enter", code: "Enter", keyCode: 13 }), 10) + return true + } + + const focusModelSelectorControl = () => { + const input = options.sidebarContentEl()?.querySelector("[data-model-selector]") + if (!input) return false + input.focus() + setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10) + return true + } + + const focusVariantSelectorControl = () => { + const input = options.sidebarContentEl()?.querySelector("[data-thinking-selector]") + if (!input) return false + input.focus() + setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10) + return true + } + + createEffect(() => { + const pending = pendingSidebarAction() + if (!pending) return + const action = pending.action + const contentReady = Boolean(options.sidebarContentEl()) + if (!contentReady) { + return + } + if (action === "show-session-list") { + setPendingSidebarAction(null) + return + } + const handled = + action === "focus-agent-selector" + ? focusAgentSelectorControl() + : action === "focus-model-selector" + ? focusModelSelectorControl() + : focusVariantSelectorControl() + if (handled) { + setPendingSidebarAction(null) + } + }) + + const handleSidebarRequest = (action: SessionSidebarRequestAction) => { + setPendingSidebarAction({ action, id: sidebarActionId++ }) + if (!options.leftPinned() && !options.leftOpen()) { + options.setLeftOpen(true) + options.measureDrawerHost() + } + } + + onMount(() => { + if (typeof window === "undefined") return + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail + if (!detail || detail.instanceId !== options.instanceId()) return + handleSidebarRequest(detail.action) + } + window.addEventListener(SESSION_SIDEBAR_EVENT, handler) + onCleanup(() => window.removeEventListener(SESSION_SIDEBAR_EVENT, handler)) + }) + + return { + handleSidebarRequest, + pendingSidebarAction, + } +} From a93252621a4c69d4622dd66a1c7d8bf819c9dfb9 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 11 Feb 2026 10:36:28 +0000 Subject: [PATCH 24/35] refactor(ui): split prompt input into hooks and API Extract prompt draft/history, attachments, picker, and keydown logic into co-located hooks. Introduce PromptInputApi for quote/expand/setText and migrate SessionView off DOM poking; remove legacy registerQuoteHandler. --- packages/ui/src/components/prompt-input.tsx | 1044 +++-------------- .../prompt-input/PromptAttachmentsBar.tsx | 50 + .../prompt-input/attachmentPlaceholders.ts | 72 ++ .../ui/src/components/prompt-input/types.ts | 26 + .../prompt-input/usePromptAttachments.ts | 296 +++++ .../prompt-input/usePromptKeyDown.ts | 272 +++++ .../prompt-input/usePromptPicker.ts | 274 +++++ .../components/prompt-input/usePromptState.ts | 193 +++ .../src/components/session/session-view.tsx | 170 +-- 9 files changed, 1409 insertions(+), 988 deletions(-) create mode 100644 packages/ui/src/components/prompt-input/PromptAttachmentsBar.tsx create mode 100644 packages/ui/src/components/prompt-input/attachmentPlaceholders.ts create mode 100644 packages/ui/src/components/prompt-input/types.ts create mode 100644 packages/ui/src/components/prompt-input/usePromptAttachments.ts create mode 100644 packages/ui/src/components/prompt-input/usePromptKeyDown.ts create mode 100644 packages/ui/src/components/prompt-input/usePromptPicker.ts create mode 100644 packages/ui/src/components/prompt-input/usePromptState.ts diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index f5f2d050..8229b877 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -1,56 +1,29 @@ -import { createSignal, Show, onMount, For, onCleanup, createEffect, on, untrack } from "solid-js" +import { createSignal, Show, onMount, onCleanup, createEffect, on, untrack } from "solid-js" import { ArrowBigUp, ArrowBigDown } from "lucide-solid" import UnifiedPicker from "./unified-picker" import ExpandButton from "./expand-button" -import { addToHistory, getHistory } from "../stores/message-history" -import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments" +import { getAttachments, clearAttachments, removeAttachment } from "../stores/attachments" import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" -import { createFileAttachment, createTextAttachment, createAgentAttachment } from "../types/attachment" -import type { Attachment } from "../types/attachment" -import type { Agent } from "../types/session" -import type { Command as SDKCommand } from "@opencode-ai/sdk/v2" import Kbd from "./kbd" import { getActiveInstance } from "../stores/instances" -import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt, executeCustomCommand } from "../stores/sessions" +import { agents, executeCustomCommand } from "../stores/sessions" import { getCommands } from "../stores/commands" import { showAlertDialog } from "../stores/alerts" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" import { preferences } from "../stores/preferences" +import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types" +import { usePromptState } from "./prompt-input/usePromptState" +import { usePromptAttachments } from "./prompt-input/usePromptAttachments" +import { usePromptPicker } from "./prompt-input/usePromptPicker" +import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown" const log = getLogger("actions") - -interface PromptInputProps { - instanceId: string - instanceFolder: string - sessionId: string - onSend: (prompt: string, attachments: Attachment[]) => Promise - onRunShell?: (command: string) => Promise - disabled?: boolean - escapeInDebounce?: boolean - isSessionBusy?: boolean - onAbortSession?: () => Promise - registerQuoteHandler?: (handler: (text: string, mode: "quote" | "code") => void) => void | (() => void) -} - export default function PromptInput(props: PromptInputProps) { const { t } = useI18n() - const [prompt, setPromptInternal] = createSignal("") - const [history, setHistory] = createSignal([]) - const HISTORY_LIMIT = 100 - const [historyIndex, setHistoryIndex] = createSignal(-1) - const [historyDraft, setHistoryDraft] = createSignal(null) const [, setIsFocused] = createSignal(false) - const [showPicker, setShowPicker] = createSignal(false) - const [pickerMode, setPickerMode] = createSignal<"mention" | "command">("mention") - const [searchQuery, setSearchQuery] = createSignal("") - const [atPosition, setAtPosition] = createSignal(null) - const [isDragging, setIsDragging] = createSignal(false) - const [ignoredAtPositions, setIgnoredAtPositions] = createSignal>(new Set()) - const [pasteCount, setPasteCount] = createSignal(0) - const [imageCount, setImageCount] = createSignal(0) - const [mode, setMode] = createSignal<"normal" | "shell">("normal") - const [expandState, setExpandState] = createSignal<"normal" | "expanded">("normal") + const [mode, setMode] = createSignal("normal") + const [expandState, setExpandState] = createSignal("normal") const SELECTION_INSERT_MAX_LENGTH = 2000 let textareaRef: HTMLTextAreaElement | undefined @@ -61,21 +34,92 @@ export default function PromptInput(props: PromptInputProps) { return t("promptInput.placeholder.default") } + const promptState = usePromptState({ + instanceId: () => props.instanceId, + sessionId: () => props.sessionId, + instanceFolder: () => props.instanceFolder, + }) + const { + prompt, + setPrompt, + clearPrompt, + draftLoadedNonce, + history, + historyIndex, + recordHistoryEntry, + clearHistoryDraft, + resetHistoryNavigation, + selectPreviousHistory, + selectNextHistory, + } = promptState - - const attachments = () => getAttachments(props.instanceId, props.sessionId) - const instanceAgents = () => agents().get(props.instanceId) || [] + const { + attachments, + isDragging, + handlePaste, + handleDragOver, + handleDragLeave, + handleDrop, + syncAttachmentCounters, + handleExpandTextAttachment, + } = usePromptAttachments({ + instanceId: () => props.instanceId, + sessionId: () => props.sessionId, + instanceFolder: () => props.instanceFolder, + prompt, + setPrompt, + getTextarea: () => textareaRef ?? null, + }) createEffect(() => { - if (!props.registerQuoteHandler) return - const cleanup = props.registerQuoteHandler((text, mode) => { - if (mode === "code") { - insertCodeSelection(text) - } else { - insertQuotedSelection(text) - } - }) + if (!props.registerPromptInputApi) return + const api: PromptInputApi = { + insertSelection: (text: string, mode: PromptInsertMode) => { + if (mode === "code") { + insertCodeSelection(text) + } else { + insertQuotedSelection(text) + } + }, + expandTextAttachment: (attachmentId: string) => { + const attachment = attachments().find((a) => a.id === attachmentId) + if (!attachment) return + handleExpandTextAttachment(attachment) + }, + setPromptText: (text: string, opts?: { focus?: boolean }) => { + const textarea = textareaRef + if (textarea) { + textarea.value = text + textarea.dispatchEvent(new Event("input", { bubbles: true })) + if (opts?.focus) { + try { + textarea.focus({ preventScroll: true } as any) + } catch { + textarea.focus() + } + } + return + } + + setPrompt(text) + if (opts?.focus) { + setTimeout(() => { + api.focus() + }, 0) + } + }, + focus: () => { + const textarea = textareaRef + if (!textarea || textarea.disabled) return + try { + textarea.focus({ preventScroll: true } as any) + } catch { + textarea.focus() + } + }, + } + const cleanup = props.registerPromptInputApi(api) onCleanup(() => { if (typeof cleanup === "function") { cleanup() @@ -83,265 +127,55 @@ export default function PromptInput(props: PromptInputProps) { }) }) - const setPrompt = (value: string) => { - setPromptInternal(value) - setSessionDraftPrompt(props.instanceId, props.sessionId, value) - } + const instanceAgents = () => agents().get(props.instanceId) || [] - const clearPrompt = () => { - clearSessionDraftPrompt(props.instanceId, props.sessionId) - setPromptInternal("") - setHistoryDraft(null) - setMode("normal") - } + const promptPicker = usePromptPicker({ + instanceId: () => props.instanceId, + sessionId: () => props.sessionId, + instanceFolder: () => props.instanceFolder, + prompt, + setPrompt, + resetHistoryNavigation, + getTextarea: () => textareaRef ?? null, + instanceAgents, + commands: () => getCommands(props.instanceId), + }) - function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) { - let highestPaste = 0 - let highestImage = 0 - - for (const match of currentPrompt.matchAll(/\[pasted #(\d+)\]/g)) { - const value = Number.parseInt(match[1], 10) - if (!Number.isNaN(value)) { - highestPaste = Math.max(highestPaste, value) - } - } - - for (const attachment of sessionAttachments) { - if (attachment.source.type === "text") { - const placeholderMatch = attachment.display.match(/pasted #(\d+)/) - if (placeholderMatch) { - const value = Number.parseInt(placeholderMatch[1], 10) - if (!Number.isNaN(value)) { - highestPaste = Math.max(highestPaste, value) - } - } - } - if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) { - const imageMatch = attachment.display.match(/Image #(\d+)/) - if (imageMatch) { - const value = Number.parseInt(imageMatch[1], 10) - if (!Number.isNaN(value)) { - highestImage = Math.max(highestImage, value) - } - } - } - } - - for (const match of currentPrompt.matchAll(/\[Image #(\d+)\]/g)) { - const value = Number.parseInt(match[1], 10) - if (!Number.isNaN(value)) { - highestImage = Math.max(highestImage, value) - } - } - - setPasteCount(highestPaste) - setImageCount(highestImage) - } + const { + showPicker, + pickerMode, + searchQuery, + ignoredAtPositions, + setShowPicker, + setPickerMode, + setSearchQuery, + setAtPosition, + setIgnoredAtPositions, + handleInput, + handlePickerSelect, + handlePickerClose, + } = promptPicker createEffect( on( - () => `${props.instanceId}:${props.sessionId}`, + draftLoadedNonce, () => { - const instanceId = props.instanceId - const sessionId = props.sessionId - - onCleanup(() => { - setSessionDraftPrompt(instanceId, sessionId, prompt()) - }) - - const storedPrompt = getSessionDraftPrompt(instanceId, sessionId) - const currentAttachments = untrack(() => getAttachments(instanceId, sessionId)) - - setPromptInternal(storedPrompt) - setSessionDraftPrompt(instanceId, sessionId, storedPrompt) - setHistoryIndex(-1) - setHistoryDraft(null) + // Session switch resets (picker/counters/ignored positions) stay in the component. setIgnoredAtPositions(new Set()) setShowPicker(false) + setPickerMode("mention") setAtPosition(null) setSearchQuery("") - syncAttachmentCounters(storedPrompt, currentAttachments) - } - ) + + const instanceId = props.instanceId + const sessionId = props.sessionId + const currentAttachments = untrack(() => getAttachments(instanceId, sessionId)) + syncAttachmentCounters(prompt(), currentAttachments) + }, + { defer: true }, + ), ) - function handleRemoveAttachment(attachmentId: string) { - const currentAttachments = attachments() - const attachment = currentAttachments.find((a) => a.id === attachmentId) - - removeAttachment(props.instanceId, props.sessionId, attachmentId) - - if (attachment) { - const currentPrompt = prompt() - let newPrompt = currentPrompt - - if (attachment.source.type === "file") { - if (attachment.mediaType.startsWith("image/")) { - const imageMatch = attachment.display.match(/\[Image #(\d+)\]/) - if (imageMatch) { - const placeholder = `[Image #${imageMatch[1]}]` - newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim() - } - } else { - const filename = attachment.filename - newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim() - } - } else if (attachment.source.type === "agent") { - const agentName = attachment.filename - newPrompt = currentPrompt.replace(`@${agentName}`, "").replace(/\s+/g, " ").trim() - } else if (attachment.source.type === "text") { - const placeholderMatch = attachment.display.match(/pasted #(\d+)/) - if (placeholderMatch) { - const placeholder = `[pasted #${placeholderMatch[1]}]` - newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim() - } - } - - setPrompt(newPrompt) - } - } - - function handleExpandTextAttachment(attachment: Attachment) { - if (attachment.source.type !== "text") return - - const textarea = textareaRef - const value = attachment.source.value - const match = attachment.display.match(/pasted #(\d+)/) - const placeholder = match ? `[pasted #${match[1]}]` : null - const currentText = prompt() - - let nextText = currentText - let selectionTarget: number | null = null - - if (placeholder) { - const placeholderIndex = currentText.indexOf(placeholder) - if (placeholderIndex !== -1) { - nextText = - currentText.substring(0, placeholderIndex) + - value + - currentText.substring(placeholderIndex + placeholder.length) - selectionTarget = placeholderIndex + value.length - } - } - - if (nextText === currentText) { - if (textarea) { - const start = textarea.selectionStart - const end = textarea.selectionEnd - nextText = currentText.substring(0, start) + value + currentText.substring(end) - selectionTarget = start + value.length - } else { - nextText = currentText + value - } - } - - setPrompt(nextText) - removeAttachment(props.instanceId, props.sessionId, attachment.id) - - if (textarea) { - setTimeout(() => { - textarea.focus() - if (selectionTarget !== null) { - textarea.setSelectionRange(selectionTarget, selectionTarget) - } - }, 0) - } - } - - async function handlePaste(e: ClipboardEvent) { - const items = e.clipboardData?.items - if (!items) return - - for (let i = 0; i < items.length; i++) { - const item = items[i] - - if (item.type.startsWith("image/")) { - e.preventDefault() - - const blob = item.getAsFile() - if (!blob) continue - - const count = imageCount() + 1 - setImageCount(count) - - const reader = new FileReader() - reader.onload = () => { - const base64Data = (reader.result as string).split(",")[1] - const display = `[Image #${count}]` - const filename = `image-${count}.png` - - const attachment = createFileAttachment( - filename, - filename, - "image/png", - new TextEncoder().encode(base64Data), - props.instanceFolder, - ) - attachment.url = `data:image/png;base64,${base64Data}` - attachment.display = display - addAttachment(props.instanceId, props.sessionId, attachment) - - const textarea = textareaRef - if (textarea) { - const start = textarea.selectionStart - const end = textarea.selectionEnd - const currentText = prompt() - const placeholder = `[Image #${count}]` - const newText = currentText.substring(0, start) + placeholder + currentText.substring(end) - setPrompt(newText) - - setTimeout(() => { - const newCursorPos = start + placeholder.length - textarea.setSelectionRange(newCursorPos, newCursorPos) - textarea.focus() - }, 0) - } - } - reader.readAsDataURL(blob) - - return - } - } - - const pastedText = e.clipboardData?.getData("text/plain") - if (!pastedText) return - - const lineCount = pastedText.split("\n").length - const charCount = pastedText.length - - const isLongPaste = charCount > 150 || lineCount > 3 - - if (isLongPaste) { - e.preventDefault() - - const count = pasteCount() + 1 - setPasteCount(count) - - const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars` - const display = `pasted #${count} (${summary})` - const filename = `paste-${count}.txt` - - const attachment = createTextAttachment(pastedText, display, filename) - addAttachment(props.instanceId, props.sessionId, attachment) - - const textarea = textareaRef - if (textarea) { - const start = textarea.selectionStart - const end = textarea.selectionEnd - const currentText = prompt() - const placeholder = `[pasted #${count}]` - const newText = currentText.substring(0, start) + placeholder + currentText.substring(end) - setPrompt(newText) - - setTimeout(() => { - const newCursorPos = start + placeholder.length - textarea.setSelectionRange(newCursorPos, newCursorPos) - textarea.focus() - }, 0) - } - } - } - onMount(() => { const handleGlobalKeyDown = (e: KeyboardEvent) => { const activeElement = document.activeElement as HTMLElement @@ -371,237 +205,8 @@ export default function PromptInput(props: PromptInputProps) { onCleanup(() => { document.removeEventListener("keydown", handleGlobalKeyDown) }) - - void (async () => { - const loaded = await getHistory(props.instanceFolder) - setHistory(loaded) - })() }) - function handleKeyDown(e: KeyboardEvent) { - const textarea = textareaRef - if (!textarea) { - return - } - - const currentText = prompt() - const cursorAtBufferStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0 - const isShellMode = mode() === "shell" - - if (!isShellMode && e.key === "!" && cursorAtBufferStart && currentText.length === 0 && !props.disabled) { - e.preventDefault() - setMode("shell") - return - } - - if (showPicker() && e.key === "Escape") { - e.preventDefault() - e.stopPropagation() - handlePickerClose() - return - } - - if (isShellMode) { - if (e.key === "Escape") { - e.preventDefault() - e.stopPropagation() - setMode("normal") - return - } - if (e.key === "Backspace" && cursorAtBufferStart && currentText.length === 0) { - e.preventDefault() - setMode("normal") - return - } - } - - if (e.key === "Backspace" || e.key === "Delete") { - const cursorPos = textarea.selectionStart - const text = currentText - - const pastePlaceholderRegex = /\[pasted #(\d+)\]/g - let pasteMatch - - while ((pasteMatch = pastePlaceholderRegex.exec(text)) !== null) { - const placeholderStart = pasteMatch.index - const placeholderEnd = pasteMatch.index + pasteMatch[0].length - const pasteNumber = pasteMatch[1] - - const isDeletingFromEnd = e.key === "Backspace" && cursorPos === placeholderEnd - const isDeletingFromStart = e.key === "Delete" && cursorPos === placeholderStart - const isSelected = - textarea.selectionStart <= placeholderStart && - textarea.selectionEnd >= placeholderEnd && - textarea.selectionStart !== textarea.selectionEnd - - if (isDeletingFromEnd || isDeletingFromStart || isSelected) { - e.preventDefault() - - const currentAttachments = attachments() - const attachment = currentAttachments.find( - (a) => a.source.type === "text" && a.display.includes(`pasted #${pasteNumber}`), - ) - - if (attachment) { - removeAttachment(props.instanceId, props.sessionId, attachment.id) - } - - const newText = text.substring(0, placeholderStart) + text.substring(placeholderEnd) - setPrompt(newText) - - setTimeout(() => { - textarea.setSelectionRange(placeholderStart, placeholderStart) - }, 0) - - return - } - } - - const imagePlaceholderRegex = /\[Image #(\d+)\]/g - let imageMatch - - while ((imageMatch = imagePlaceholderRegex.exec(text)) !== null) { - const placeholderStart = imageMatch.index - const placeholderEnd = imageMatch.index + imageMatch[0].length - const imageNumber = imageMatch[1] - - const isDeletingFromEnd = e.key === "Backspace" && cursorPos === placeholderEnd - const isDeletingFromStart = e.key === "Delete" && cursorPos === placeholderStart - const isSelected = - textarea.selectionStart <= placeholderStart && - textarea.selectionEnd >= placeholderEnd && - textarea.selectionStart !== textarea.selectionEnd - - if (isDeletingFromEnd || isDeletingFromStart || isSelected) { - e.preventDefault() - - const currentAttachments = attachments() - const attachment = currentAttachments.find( - (a) => - a.source.type === "file" && - a.mediaType.startsWith("image/") && - a.display.includes(`Image #${imageNumber}`), - ) - - if (attachment) { - removeAttachment(props.instanceId, props.sessionId, attachment.id) - } - - const newText = text.substring(0, placeholderStart) + text.substring(placeholderEnd) - setPrompt(newText) - - setTimeout(() => { - textarea.setSelectionRange(placeholderStart, placeholderStart) - }, 0) - - return - } - } - - const mentionRegex = /@(\S+)/g - let mentionMatch - - while ((mentionMatch = mentionRegex.exec(text)) !== null) { - const mentionStart = mentionMatch.index - const mentionEnd = mentionMatch.index + mentionMatch[0].length - const name = mentionMatch[1] - - const isDeletingFromEnd = e.key === "Backspace" && cursorPos === mentionEnd - const isDeletingFromStart = e.key === "Delete" && cursorPos === mentionStart - const isSelected = - textarea.selectionStart <= mentionStart && - textarea.selectionEnd >= mentionEnd && - textarea.selectionStart !== textarea.selectionEnd - - if (isDeletingFromEnd || isDeletingFromStart || isSelected) { - const currentAttachments = attachments() - const attachment = currentAttachments.find( - (a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name, - ) - - if (attachment) { - e.preventDefault() - - removeAttachment(props.instanceId, props.sessionId, attachment.id) - - setIgnoredAtPositions((prev) => { - const next = new Set(prev) - next.delete(mentionStart) - return next - }) - - const newText = text.substring(0, mentionStart) + text.substring(mentionEnd) - setPrompt(newText) - - setTimeout(() => { - textarea.setSelectionRange(mentionStart, mentionStart) - }, 0) - - return - } - } - } - } - - if (e.key === "Enter") { - const isModified = e.metaKey || e.ctrlKey - - // If the picker is open, Enter should select from it. - if (!isModified && showPicker()) { - return - } - - if (submitOnEnter()) { - // Swapped mode: Enter submits, Cmd/Ctrl+Enter inserts a newline. - if (isModified) { - e.preventDefault() - e.stopPropagation() - insertNewlineAtCursor() - return - } - - // Shift+Enter always inserts a newline. - if (e.shiftKey) { - // If the picker is open, avoid selecting an item on Enter. - if (showPicker()) { - e.stopPropagation() - } - return - } - - e.preventDefault() - handleSend() - return - } - - // Default: Cmd/Ctrl+Enter submits. - if (isModified) { - e.preventDefault() - if (showPicker()) { - handlePickerClose() - } - handleSend() - return - } - } - - if (e.key === "ArrowUp") { - const handled = selectPreviousHistory() - if (handled) { - e.preventDefault() - return - } - } - - if (e.key === "ArrowDown") { - const handled = selectNextHistory() - if (handled) { - e.preventDefault() - return - } - } - } - async function handleSend() { const text = prompt().trim() const currentAttachments = attachments() @@ -624,37 +229,24 @@ export default function PromptInput(props: PromptInputProps) { const resolvedPrompt = isKnownSlashCommand ? text : resolvePastedPlaceholders(text, currentAttachments) const historyEntry = resolvedPrompt - const refreshHistory = async () => { - try { - await addToHistory(props.instanceFolder, historyEntry) - setHistory((prev) => { - const next = [historyEntry, ...prev] - if (next.length > HISTORY_LIMIT) { - next.length = HISTORY_LIMIT - } - return next - }) - setHistoryIndex(-1) - } catch (historyError) { - log.error("Failed to update prompt history:", historyError) - } - } + const refreshHistory = () => recordHistoryEntry(historyEntry) setExpandState("normal") clearPrompt() + clearHistoryDraft() + setMode("normal") // Ignore attachments for slash commands, but keep them for next prompt. if (!isKnownSlashCommand) { clearAttachments(props.instanceId, props.sessionId) - setPasteCount(0) - setImageCount(0) + syncAttachmentCounters("", []) setIgnoredAtPositions(new Set()) } else { syncAttachmentCounters("", currentAttachments) setIgnoredAtPositions(new Set()) } - setHistoryDraft(null) + clearHistoryDraft() if (isKnownSlashCommand) { // Record attempted slash commands even if execution fails. @@ -688,60 +280,6 @@ export default function PromptInput(props: PromptInputProps) { } } - function focusTextareaEnd() { - if (!textareaRef) return - setTimeout(() => { - if (!textareaRef) return - const pos = textareaRef.value.length - textareaRef.setSelectionRange(pos, pos) - textareaRef.focus() - }, 0) - } - - function canUseHistory(force = false) { - if (force) return true - if (showPicker()) return false - const textarea = textareaRef - if (!textarea) return false - return textarea.selectionStart === 0 && textarea.selectionEnd === 0 - } - - function selectPreviousHistory(force = false) { - const entries = history() - if (entries.length === 0) return false - if (!canUseHistory(force)) return false - - if (historyIndex() === -1) { - setHistoryDraft(prompt()) - } - - const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, entries.length - 1) - setHistoryIndex(newIndex) - setPrompt(entries[newIndex]) - focusTextareaEnd() - return true - } - - function selectNextHistory(force = false) { - const entries = history() - if (entries.length === 0) return false - if (!canUseHistory(force)) return false - if (historyIndex() === -1) return false - - const newIndex = historyIndex() - 1 - if (newIndex >= 0) { - setHistoryIndex(newIndex) - setPrompt(entries[newIndex]) - } else { - setHistoryIndex(-1) - const draft = historyDraft() - setPrompt(draft ?? "") - setHistoryDraft(null) - } - focusTextareaEnd() - return true - } - function handleAbort() { if (!props.onAbortSession || !props.isSessionBusy) return void props.onAbortSession() @@ -753,266 +291,6 @@ export default function PromptInput(props: PromptInputProps) { textareaRef?.focus() } - function handleInput(e: Event) { - - const target = e.target as HTMLTextAreaElement - const value = target.value - setPrompt(value) - setHistoryIndex(-1) - setHistoryDraft(null) - - const cursorPos = target.selectionStart - - // Slash command picker (only when editing the command token: "/") - if (value.startsWith("/") && cursorPos >= 1) { - const firstWhitespaceIndex = value.slice(1).search(/\s/) - const tokenEnd = firstWhitespaceIndex === -1 ? value.length : firstWhitespaceIndex + 1 - - if (cursorPos <= tokenEnd) { - setPickerMode("command") - setAtPosition(0) - setSearchQuery(value.substring(1, cursorPos)) - setShowPicker(true) - return - } - } - - const textBeforeCursor = value.substring(0, cursorPos) - const lastAtIndex = textBeforeCursor.lastIndexOf("@") - - const previousAtPosition = atPosition() - - - if (lastAtIndex === -1) { - setIgnoredAtPositions(new Set()) - } else if (previousAtPosition !== null && lastAtIndex !== previousAtPosition) { - setIgnoredAtPositions((prev) => { - const next = new Set(prev) - next.delete(previousAtPosition) - return next - }) - } - - if (lastAtIndex !== -1) { - const textAfterAt = value.substring(lastAtIndex + 1, cursorPos) - const hasSpace = textAfterAt.includes(" ") || textAfterAt.includes("\n") - - if (!hasSpace && cursorPos === lastAtIndex + textAfterAt.length + 1) { - if (!ignoredAtPositions().has(lastAtIndex)) { - setPickerMode("mention") - setAtPosition(lastAtIndex) - setSearchQuery(textAfterAt) - setShowPicker(true) - } - return - } - } - - setShowPicker(false) - setAtPosition(null) - } - - function handlePickerSelect( - item: - | { type: "agent"; agent: Agent } - | { - type: "file" - file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean } - } - | { type: "command"; command: SDKCommand }, - ) { - if (item.type === "command") { - const name = item.command.name - const currentPrompt = prompt() - - const afterSlash = currentPrompt.slice(1) - const firstWhitespaceIndex = afterSlash.search(/\s/) - const tokenEnd = firstWhitespaceIndex === -1 ? currentPrompt.length : firstWhitespaceIndex + 1 - - const before = "" - const after = currentPrompt.substring(tokenEnd) - const newPrompt = before + `/${name} ` + after - setPrompt(newPrompt) - - setTimeout(() => { - if (textareaRef) { - const newCursorPos = `/${name} `.length - textareaRef.setSelectionRange(newCursorPos, newCursorPos) - textareaRef.focus() - } - }, 0) - } else if (item.type === "agent") { - const agentName = item.agent.name - const existingAttachments = attachments() - const alreadyAttached = existingAttachments.some( - (att) => att.source.type === "agent" && att.source.name === agentName, - ) - - if (!alreadyAttached) { - const attachment = createAgentAttachment(agentName) - addAttachment(props.instanceId, props.sessionId, attachment) - } - - const currentPrompt = prompt() - const pos = atPosition() - const cursorPos = textareaRef?.selectionStart || 0 - - if (pos !== null) { - const before = currentPrompt.substring(0, pos) - const after = currentPrompt.substring(cursorPos) - const attachmentText = `@${agentName}` - const newPrompt = before + attachmentText + " " + after - setPrompt(newPrompt) - - setTimeout(() => { - if (textareaRef) { - const newCursorPos = pos + attachmentText.length + 1 - textareaRef.setSelectionRange(newCursorPos, newCursorPos) - } - }, 0) - } - } else if (item.type === "file") { - const displayPath = item.file.path - const relativePath = item.file.relativePath ?? displayPath - const isFolder = item.file.isDirectory ?? displayPath.endsWith("/") - - if (isFolder) { - const currentPrompt = prompt() - const pos = atPosition() - const cursorPos = textareaRef?.selectionStart || 0 - const folderMention = - relativePath === "." || relativePath === "" - ? "/" - : relativePath.replace(/\/+$/, "") + "/" - - if (pos !== null) { - const before = currentPrompt.substring(0, pos + 1) - const after = currentPrompt.substring(cursorPos) - const newPrompt = before + folderMention + after - setPrompt(newPrompt) - setSearchQuery(folderMention) - - setTimeout(() => { - if (textareaRef) { - const newCursorPos = pos + 1 + folderMention.length - textareaRef.setSelectionRange(newCursorPos, newCursorPos) - } - }, 0) - } - - return - } - - const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath - const pathSegments = normalizedPath.split("/") - const filename = (() => { - const candidate = pathSegments[pathSegments.length - 1] || normalizedPath - return candidate === "." ? "/" : candidate - })() - - const existingAttachments = attachments() - const alreadyAttached = existingAttachments.some( - (att) => att.source.type === "file" && att.source.path === normalizedPath, - ) - - if (!alreadyAttached) { - const attachment = createFileAttachment(normalizedPath, filename, "text/plain", undefined, props.instanceFolder) - addAttachment(props.instanceId, props.sessionId, attachment) - } - - const currentPrompt = prompt() - const pos = atPosition() - const cursorPos = textareaRef?.selectionStart || 0 - - if (pos !== null) { - const before = currentPrompt.substring(0, pos) - const after = currentPrompt.substring(cursorPos) - const attachmentText = `@${normalizedPath}` - const newPrompt = before + attachmentText + " " + after - setPrompt(newPrompt) - - setTimeout(() => { - if (textareaRef) { - const newCursorPos = pos + attachmentText.length + 1 - textareaRef.setSelectionRange(newCursorPos, newCursorPos) - } - }, 0) - } - } - - setShowPicker(false) - setAtPosition(null) - setSearchQuery("") - textareaRef?.focus() - } - - function handlePickerClose() { - const pos = atPosition() - if (pickerMode() === "mention" && pos !== null) { - setIgnoredAtPositions((prev) => new Set(prev).add(pos)) - } - setShowPicker(false) - setAtPosition(null) - setSearchQuery("") - setTimeout(() => textareaRef?.focus(), 0) - } - - function handleDragOver(e: DragEvent) { - e.preventDefault() - e.stopPropagation() - setIsDragging(true) - } - - function handleDragLeave(e: DragEvent) { - e.preventDefault() - e.stopPropagation() - setIsDragging(false) - } - - function handleDrop(e: DragEvent) { - e.preventDefault() - e.stopPropagation() - setIsDragging(false) - - const files = e.dataTransfer?.files - if (!files || files.length === 0) return - - for (let i = 0; i < files.length; i++) { - const file = files[i] - const path = (file as File & { path?: string }).path || file.name - const filename = file.name - const mime = file.type || "text/plain" - - const createAndStoreAttachment = (previewUrl?: string) => { - const attachment = createFileAttachment(path, filename, mime, undefined, props.instanceFolder) - if (previewUrl && (mime.startsWith("image/") || mime.startsWith("text/"))) { - attachment.url = previewUrl - } - addAttachment(props.instanceId, props.sessionId, attachment) - } - - if (mime.startsWith("image/") && typeof FileReader !== "undefined") { - const reader = new FileReader() - reader.onload = () => { - const result = typeof reader.result === "string" ? reader.result : undefined - createAndStoreAttachment(result) - } - reader.readAsDataURL(file) - } else if (mime.startsWith("text/") && typeof FileReader !== "undefined") { - const reader = new FileReader() - reader.onload = () => { - const dataUrl = typeof reader.result === "string" ? reader.result : undefined - createAndStoreAttachment(dataUrl) - } - reader.readAsDataURL(file) - } else { - createAndStoreAttachment() - } - } - - textareaRef?.focus() - } - function insertBlockContent(block: string) { const textarea = textareaRef const current = prompt() @@ -1025,8 +303,7 @@ export default function PromptInput(props: PromptInputProps) { const nextValue = before + insertion + after setPrompt(nextValue) - setHistoryIndex(-1) - setHistoryDraft(null) + resetHistoryNavigation() setShowPicker(false) setAtPosition(null) @@ -1092,22 +369,25 @@ export default function PromptInput(props: PromptInputProps) { const submitOnEnter = () => preferences().promptSubmitOnEnter - function insertNewlineAtCursor() { - const textarea = textareaRef - const current = prompt() - const start = textarea ? textarea.selectionStart : current.length - const end = textarea ? textarea.selectionEnd : current.length - const nextValue = current.substring(0, start) + "\n" + current.substring(end) - const nextCursor = start + 1 - - setPrompt(nextValue) - - setTimeout(() => { - if (!textareaRef) return - textareaRef.focus() - textareaRef.setSelectionRange(nextCursor, nextCursor) - }, 0) - } + const handleKeyDown = usePromptKeyDown({ + getTextarea: () => textareaRef ?? null, + prompt, + setPrompt, + mode, + setMode, + isPickerOpen: showPicker, + closePicker: handlePickerClose, + ignoredAtPositions, + setIgnoredAtPositions, + getAttachments: attachments, + removeAttachment: (attachmentId) => removeAttachment(props.instanceId, props.sessionId, attachmentId), + submitOnEnter, + onSend: () => void handleSend(), + selectPreviousHistory: (force) => + selectPreviousHistory({ force, isPickerOpen: showPicker(), getTextarea: () => textareaRef ?? null }), + selectNextHistory: (force) => + selectNextHistory({ force, isPickerOpen: showPicker(), getTextarea: () => textareaRef ?? null }), + }) const shouldShowOverlay = () => prompt().length === 0 @@ -1171,7 +451,13 @@ export default function PromptInput(props: PromptInputProps) { + + + + ) + }} +
+ + ) +} + +export default PromptAttachmentsBar diff --git a/packages/ui/src/components/prompt-input/attachmentPlaceholders.ts b/packages/ui/src/components/prompt-input/attachmentPlaceholders.ts new file mode 100644 index 00000000..3139cc06 --- /dev/null +++ b/packages/ui/src/components/prompt-input/attachmentPlaceholders.ts @@ -0,0 +1,72 @@ +import type { Attachment } from "../../types/attachment" + +export function formatPastedPlaceholder(value: string | number) { + return `[pasted #${value}]` +} + +export function formatImagePlaceholder(value: string | number) { + return `[Image #${value}]` +} + +export function createPastedPlaceholderRegex() { + return /\[pasted #(\d+)\]/g +} + +export function createImagePlaceholderRegex() { + return /\[Image #(\d+)\]/g +} + +export function createMentionRegex() { + return /@(\S+)/g +} + +export const pastedDisplayCounterRegex = /pasted #(\d+)/ +export const imageDisplayCounterRegex = /Image #(\d+)/ +export const bracketedImageDisplayCounterRegex = /\[Image #(\d+)\]/ + +export function parseCounter(value: string) { + const parsed = Number.parseInt(value, 10) + return Number.isNaN(parsed) ? null : parsed +} + +export function findHighestAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) { + let highestPaste = 0 + let highestImage = 0 + + for (const match of currentPrompt.matchAll(createPastedPlaceholderRegex())) { + const parsed = parseCounter(match[1]) + if (parsed !== null) { + highestPaste = Math.max(highestPaste, parsed) + } + } + + for (const attachment of sessionAttachments) { + if (attachment.source.type === "text") { + const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex) + if (placeholderMatch) { + const parsed = parseCounter(placeholderMatch[1]) + if (parsed !== null) { + highestPaste = Math.max(highestPaste, parsed) + } + } + } + if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) { + const imageMatch = attachment.display.match(imageDisplayCounterRegex) + if (imageMatch) { + const parsed = parseCounter(imageMatch[1]) + if (parsed !== null) { + highestImage = Math.max(highestImage, parsed) + } + } + } + } + + for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) { + const parsed = parseCounter(match[1]) + if (parsed !== null) { + highestImage = Math.max(highestImage, parsed) + } + } + + return { highestPaste, highestImage } +} diff --git a/packages/ui/src/components/prompt-input/types.ts b/packages/ui/src/components/prompt-input/types.ts new file mode 100644 index 00000000..b3ff1a39 --- /dev/null +++ b/packages/ui/src/components/prompt-input/types.ts @@ -0,0 +1,26 @@ +import type { Attachment } from "../../types/attachment" + +export type PromptMode = "normal" | "shell" +export type ExpandState = "normal" | "expanded" +export type PickerMode = "mention" | "command" +export type PromptInsertMode = "quote" | "code" + +export interface PromptInputApi { + insertSelection(text: string, mode: PromptInsertMode): void + expandTextAttachment(attachmentId: string): void + setPromptText(text: string, opts?: { focus?: boolean }): void + focus(): void +} + +export interface PromptInputProps { + instanceId: string + instanceFolder: string + sessionId: string + onSend: (prompt: string, attachments: Attachment[]) => Promise + onRunShell?: (command: string) => Promise + disabled?: boolean + escapeInDebounce?: boolean + isSessionBusy?: boolean + onAbortSession?: () => Promise + registerPromptInputApi?: (api: PromptInputApi) => void | (() => void) +} diff --git a/packages/ui/src/components/prompt-input/usePromptAttachments.ts b/packages/ui/src/components/prompt-input/usePromptAttachments.ts new file mode 100644 index 00000000..fa833cb2 --- /dev/null +++ b/packages/ui/src/components/prompt-input/usePromptAttachments.ts @@ -0,0 +1,296 @@ +import { createSignal, type Accessor } from "solid-js" +import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments" +import { createFileAttachment, createTextAttachment } from "../../types/attachment" +import type { Attachment } from "../../types/attachment" +import { + bracketedImageDisplayCounterRegex, + findHighestAttachmentCounters, + formatImagePlaceholder, + formatPastedPlaceholder, + pastedDisplayCounterRegex, +} from "./attachmentPlaceholders" + +type PromptAttachmentsOptions = { + instanceId: Accessor + sessionId: Accessor + instanceFolder: Accessor + prompt: Accessor + setPrompt: (value: string) => void + getTextarea: () => HTMLTextAreaElement | null +} + +type PromptAttachments = { + attachments: Accessor + pasteCount: Accessor + imageCount: Accessor + syncAttachmentCounters: (promptText: string, sessionAttachments: Attachment[]) => void + + handlePaste: (e: ClipboardEvent) => Promise + isDragging: Accessor + handleDragOver: (e: DragEvent) => void + handleDragLeave: (e: DragEvent) => void + handleDrop: (e: DragEvent) => void + + handleRemoveAttachment: (attachmentId: string) => void + handleExpandTextAttachment: (attachment: Attachment) => void +} + +export function usePromptAttachments(options: PromptAttachmentsOptions): PromptAttachments { + const attachments = () => getAttachments(options.instanceId(), options.sessionId()) + const [isDragging, setIsDragging] = createSignal(false) + const [pasteCount, setPasteCount] = createSignal(0) + const [imageCount, setImageCount] = createSignal(0) + + function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) { + const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt, sessionAttachments) + setPasteCount(highestPaste) + setImageCount(highestImage) + } + + function handleRemoveAttachment(attachmentId: string) { + const currentAttachments = attachments() + const attachment = currentAttachments.find((a) => a.id === attachmentId) + + removeAttachment(options.instanceId(), options.sessionId(), attachmentId) + + if (attachment) { + const currentPrompt = options.prompt() + let newPrompt = currentPrompt + + if (attachment.source.type === "file") { + if (attachment.mediaType.startsWith("image/")) { + const imageMatch = attachment.display.match(bracketedImageDisplayCounterRegex) + if (imageMatch) { + const placeholder = formatImagePlaceholder(imageMatch[1]) + newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim() + } + } else { + const filename = attachment.filename + newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim() + } + } else if (attachment.source.type === "agent") { + const agentName = attachment.filename + newPrompt = currentPrompt.replace(`@${agentName}`, "").replace(/\s+/g, " ").trim() + } else if (attachment.source.type === "text") { + const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex) + if (placeholderMatch) { + const placeholder = formatPastedPlaceholder(placeholderMatch[1]) + newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim() + } + } + + options.setPrompt(newPrompt) + } + } + + function handleExpandTextAttachment(attachment: Attachment) { + if (attachment.source.type !== "text") return + + const textarea = options.getTextarea() + const value = attachment.source.value + const match = attachment.display.match(pastedDisplayCounterRegex) + const placeholder = match ? formatPastedPlaceholder(match[1]) : null + const currentText = options.prompt() + + let nextText = currentText + let selectionTarget: number | null = null + + if (placeholder) { + const placeholderIndex = currentText.indexOf(placeholder) + if (placeholderIndex !== -1) { + nextText = + currentText.substring(0, placeholderIndex) + + value + + currentText.substring(placeholderIndex + placeholder.length) + selectionTarget = placeholderIndex + value.length + } + } + + if (nextText === currentText) { + if (textarea) { + const start = textarea.selectionStart + const end = textarea.selectionEnd + nextText = currentText.substring(0, start) + value + currentText.substring(end) + selectionTarget = start + value.length + } else { + nextText = currentText + value + } + } + + options.setPrompt(nextText) + removeAttachment(options.instanceId(), options.sessionId(), attachment.id) + + if (textarea) { + setTimeout(() => { + textarea.focus() + if (selectionTarget !== null) { + textarea.setSelectionRange(selectionTarget, selectionTarget) + } + }, 0) + } + } + + async function handlePaste(e: ClipboardEvent) { + const items = e.clipboardData?.items + if (!items) return + + for (let i = 0; i < items.length; i++) { + const item = items[i] + + if (item.type.startsWith("image/")) { + e.preventDefault() + + const blob = item.getAsFile() + if (!blob) continue + + const count = imageCount() + 1 + setImageCount(count) + + const reader = new FileReader() + reader.onload = () => { + const base64Data = (reader.result as string).split(",")[1] + const display = formatImagePlaceholder(count) + const filename = `image-${count}.png` + + const attachment = createFileAttachment( + filename, + filename, + "image/png", + new TextEncoder().encode(base64Data), + options.instanceFolder(), + ) + attachment.url = `data:image/png;base64,${base64Data}` + attachment.display = display + addAttachment(options.instanceId(), options.sessionId(), attachment) + + const textarea = options.getTextarea() + if (textarea) { + const start = textarea.selectionStart + const end = textarea.selectionEnd + const currentText = options.prompt() + const placeholder = formatImagePlaceholder(count) + const newText = currentText.substring(0, start) + placeholder + currentText.substring(end) + options.setPrompt(newText) + + setTimeout(() => { + const newCursorPos = start + placeholder.length + textarea.setSelectionRange(newCursorPos, newCursorPos) + textarea.focus() + }, 0) + } + } + reader.readAsDataURL(blob) + + return + } + } + + const pastedText = e.clipboardData?.getData("text/plain") + if (!pastedText) return + + const lineCount = pastedText.split("\n").length + const charCount = pastedText.length + + const isLongPaste = charCount > 150 || lineCount > 3 + + if (isLongPaste) { + e.preventDefault() + + const count = pasteCount() + 1 + setPasteCount(count) + + const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars` + const display = `pasted #${count} (${summary})` + const filename = `paste-${count}.txt` + + const attachment = createTextAttachment(pastedText, display, filename) + addAttachment(options.instanceId(), options.sessionId(), attachment) + + const textarea = options.getTextarea() + if (textarea) { + const start = textarea.selectionStart + const end = textarea.selectionEnd + const currentText = options.prompt() + const placeholder = formatPastedPlaceholder(count) + const newText = currentText.substring(0, start) + placeholder + currentText.substring(end) + options.setPrompt(newText) + + setTimeout(() => { + const newCursorPos = start + placeholder.length + textarea.setSelectionRange(newCursorPos, newCursorPos) + textarea.focus() + }, 0) + } + } + } + + function handleDragOver(e: DragEvent) { + e.preventDefault() + e.stopPropagation() + setIsDragging(true) + } + + function handleDragLeave(e: DragEvent) { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + } + + function handleDrop(e: DragEvent) { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + + const files = e.dataTransfer?.files + if (!files || files.length === 0) return + + for (let i = 0; i < files.length; i++) { + const file = files[i] + const path = (file as File & { path?: string }).path || file.name + const filename = file.name + const mime = file.type || "text/plain" + + const createAndStoreAttachment = (previewUrl?: string) => { + const attachment = createFileAttachment(path, filename, mime, undefined, options.instanceFolder()) + if (previewUrl && (mime.startsWith("image/") || mime.startsWith("text/"))) { + attachment.url = previewUrl + } + addAttachment(options.instanceId(), options.sessionId(), attachment) + } + + if (mime.startsWith("image/") && typeof FileReader !== "undefined") { + const reader = new FileReader() + reader.onload = () => { + const result = typeof reader.result === "string" ? reader.result : undefined + createAndStoreAttachment(result) + } + reader.readAsDataURL(file) + } else if (mime.startsWith("text/") && typeof FileReader !== "undefined") { + const reader = new FileReader() + reader.onload = () => { + const dataUrl = typeof reader.result === "string" ? reader.result : undefined + createAndStoreAttachment(dataUrl) + } + reader.readAsDataURL(file) + } else { + createAndStoreAttachment() + } + } + + options.getTextarea()?.focus() + } + + return { + attachments, + pasteCount, + imageCount, + syncAttachmentCounters, + handlePaste, + isDragging, + handleDragOver, + handleDragLeave, + handleDrop, + handleRemoveAttachment, + handleExpandTextAttachment, + } +} diff --git a/packages/ui/src/components/prompt-input/usePromptKeyDown.ts b/packages/ui/src/components/prompt-input/usePromptKeyDown.ts new file mode 100644 index 00000000..18d1746e --- /dev/null +++ b/packages/ui/src/components/prompt-input/usePromptKeyDown.ts @@ -0,0 +1,272 @@ +import type { Accessor } from "solid-js" +import type { Attachment } from "../../types/attachment" +import type { PromptMode } from "./types" +import { + createImagePlaceholderRegex, + createMentionRegex, + createPastedPlaceholderRegex, +} from "./attachmentPlaceholders" + +export type UsePromptKeyDownOptions = { + getTextarea: () => HTMLTextAreaElement | null + + prompt: Accessor + setPrompt: (v: string) => void + + mode: Accessor + setMode: (m: PromptMode) => void + + isPickerOpen: Accessor + closePicker: () => void + + ignoredAtPositions: Accessor> + setIgnoredAtPositions: (next: Set | ((s: Set) => Set)) => void + + getAttachments: Accessor + removeAttachment: (attachmentId: string) => void + + submitOnEnter: Accessor + onSend: () => void + + selectPreviousHistory: (force?: boolean) => boolean + selectNextHistory: (force?: boolean) => boolean +} + +export function usePromptKeyDown(options: UsePromptKeyDownOptions) { + const insertNewlineAtCursor = () => { + const textarea = options.getTextarea() + const current = options.prompt() + const start = textarea ? textarea.selectionStart : current.length + const end = textarea ? textarea.selectionEnd : current.length + const nextValue = current.substring(0, start) + "\n" + current.substring(end) + const nextCursor = start + 1 + + options.setPrompt(nextValue) + + setTimeout(() => { + const nextTextarea = options.getTextarea() + if (!nextTextarea) return + nextTextarea.focus() + nextTextarea.setSelectionRange(nextCursor, nextCursor) + }, 0) + } + + return function handleKeyDown(e: KeyboardEvent) { + const textarea = options.getTextarea() + if (!textarea) return + + const currentText = options.prompt() + const cursorAtBufferStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0 + const isShellMode = options.mode() === "shell" + + if (!isShellMode && e.key === "!" && cursorAtBufferStart && currentText.length === 0 && !textarea.disabled) { + e.preventDefault() + options.setMode("shell") + return + } + + if (options.isPickerOpen() && e.key === "Escape") { + e.preventDefault() + e.stopPropagation() + options.closePicker() + return + } + + if (isShellMode) { + if (e.key === "Escape") { + e.preventDefault() + e.stopPropagation() + options.setMode("normal") + return + } + if (e.key === "Backspace" && cursorAtBufferStart && currentText.length === 0) { + e.preventDefault() + options.setMode("normal") + return + } + } + + if (e.key === "Backspace" || e.key === "Delete") { + const cursorPos = textarea.selectionStart + const text = currentText + + const pastePlaceholderRegex = createPastedPlaceholderRegex() + let pasteMatch + + while ((pasteMatch = pastePlaceholderRegex.exec(text)) !== null) { + const placeholderStart = pasteMatch.index + const placeholderEnd = pasteMatch.index + pasteMatch[0].length + const pasteNumber = pasteMatch[1] + + const isDeletingFromEnd = e.key === "Backspace" && cursorPos === placeholderEnd + const isDeletingFromStart = e.key === "Delete" && cursorPos === placeholderStart + const isSelected = + textarea.selectionStart <= placeholderStart && + textarea.selectionEnd >= placeholderEnd && + textarea.selectionStart !== textarea.selectionEnd + + if (isDeletingFromEnd || isDeletingFromStart || isSelected) { + e.preventDefault() + + const currentAttachments = options.getAttachments() + const attachment = currentAttachments.find( + (a) => a.source.type === "text" && a.display.includes(`pasted #${pasteNumber}`), + ) + + if (attachment) { + options.removeAttachment(attachment.id) + } + + const newText = text.substring(0, placeholderStart) + text.substring(placeholderEnd) + options.setPrompt(newText) + + setTimeout(() => { + textarea.setSelectionRange(placeholderStart, placeholderStart) + }, 0) + + return + } + } + + const imagePlaceholderRegex = createImagePlaceholderRegex() + let imageMatch + + while ((imageMatch = imagePlaceholderRegex.exec(text)) !== null) { + const placeholderStart = imageMatch.index + const placeholderEnd = imageMatch.index + imageMatch[0].length + const imageNumber = imageMatch[1] + + const isDeletingFromEnd = e.key === "Backspace" && cursorPos === placeholderEnd + const isDeletingFromStart = e.key === "Delete" && cursorPos === placeholderStart + const isSelected = + textarea.selectionStart <= placeholderStart && + textarea.selectionEnd >= placeholderEnd && + textarea.selectionStart !== textarea.selectionEnd + + if (isDeletingFromEnd || isDeletingFromStart || isSelected) { + e.preventDefault() + + const currentAttachments = options.getAttachments() + const attachment = currentAttachments.find( + (a) => a.source.type === "file" && a.mediaType.startsWith("image/") && a.display.includes(`Image #${imageNumber}`), + ) + + if (attachment) { + options.removeAttachment(attachment.id) + } + + const newText = text.substring(0, placeholderStart) + text.substring(placeholderEnd) + options.setPrompt(newText) + + setTimeout(() => { + textarea.setSelectionRange(placeholderStart, placeholderStart) + }, 0) + + return + } + } + + const mentionRegex = createMentionRegex() + let mentionMatch + + while ((mentionMatch = mentionRegex.exec(text)) !== null) { + const mentionStart = mentionMatch.index + const mentionEnd = mentionMatch.index + mentionMatch[0].length + const name = mentionMatch[1] + + const isDeletingFromEnd = e.key === "Backspace" && cursorPos === mentionEnd + const isDeletingFromStart = e.key === "Delete" && cursorPos === mentionStart + const isSelected = + textarea.selectionStart <= mentionStart && + textarea.selectionEnd >= mentionEnd && + textarea.selectionStart !== textarea.selectionEnd + + if (isDeletingFromEnd || isDeletingFromStart || isSelected) { + const currentAttachments = options.getAttachments() + const attachment = currentAttachments.find( + (a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name, + ) + + if (attachment) { + e.preventDefault() + + options.removeAttachment(attachment.id) + + options.setIgnoredAtPositions((prev) => { + const next = new Set(prev) + next.delete(mentionStart) + return next + }) + + const newText = text.substring(0, mentionStart) + text.substring(mentionEnd) + options.setPrompt(newText) + + setTimeout(() => { + textarea.setSelectionRange(mentionStart, mentionStart) + }, 0) + + return + } + } + } + } + + if (e.key === "Enter") { + const isModified = e.metaKey || e.ctrlKey + + // If the picker is open, Enter should select from it. + if (!isModified && options.isPickerOpen()) { + return + } + + if (options.submitOnEnter()) { + // Swapped mode: Enter submits, Cmd/Ctrl+Enter inserts a newline. + if (isModified) { + e.preventDefault() + e.stopPropagation() + insertNewlineAtCursor() + return + } + + // Shift+Enter always inserts a newline. + if (e.shiftKey) { + // If the picker is open, avoid selecting an item on Enter. + if (options.isPickerOpen()) { + e.stopPropagation() + } + return + } + + e.preventDefault() + options.onSend() + return + } + + // Default: Cmd/Ctrl+Enter submits. + if (isModified) { + e.preventDefault() + if (options.isPickerOpen()) { + options.closePicker() + } + options.onSend() + return + } + } + + if (e.key === "ArrowUp") { + const handled = options.selectPreviousHistory() + if (handled) { + e.preventDefault() + return + } + } + + if (e.key === "ArrowDown") { + const handled = options.selectNextHistory() + if (handled) { + e.preventDefault() + return + } + } + } +} diff --git a/packages/ui/src/components/prompt-input/usePromptPicker.ts b/packages/ui/src/components/prompt-input/usePromptPicker.ts new file mode 100644 index 00000000..958f4a4c --- /dev/null +++ b/packages/ui/src/components/prompt-input/usePromptPicker.ts @@ -0,0 +1,274 @@ +import { createSignal, type Accessor, type Setter } from "solid-js" +import type { Command as SDKCommand } from "@opencode-ai/sdk/v2" +import type { Agent } from "../../types/session" +import { createAgentAttachment, createFileAttachment } from "../../types/attachment" +import { addAttachment, getAttachments } from "../../stores/attachments" +import type { PickerMode } from "./types" + +type PickerItem = + | { type: "agent"; agent: Agent } + | { type: "file"; file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean } } + | { type: "command"; command: SDKCommand } + +type PromptPickerOptions = { + instanceId: Accessor + sessionId: Accessor + instanceFolder: Accessor + + prompt: Accessor + setPrompt: (value: string) => void + resetHistoryNavigation?: () => void + getTextarea: () => HTMLTextAreaElement | null + + instanceAgents: Accessor + commands: Accessor +} + +type PromptPickerController = { + showPicker: Accessor + pickerMode: Accessor + searchQuery: Accessor + atPosition: Accessor + ignoredAtPositions: Accessor> + + setShowPicker: Setter + setPickerMode: Setter + setSearchQuery: Setter + setAtPosition: Setter + setIgnoredAtPositions: Setter> + + handleInput: (e: Event) => void + handlePickerSelect: (item: PickerItem) => void + handlePickerClose: () => void +} + +export function usePromptPicker(options: PromptPickerOptions): PromptPickerController { + const [showPicker, setShowPicker] = createSignal(false) + const [pickerMode, setPickerMode] = createSignal("mention") + const [searchQuery, setSearchQuery] = createSignal("") + const [atPosition, setAtPosition] = createSignal(null) + const [ignoredAtPositions, setIgnoredAtPositions] = createSignal>(new Set()) + + function handleInput(e: Event) { + const target = e.target as HTMLTextAreaElement + const value = target.value + options.setPrompt(value) + options.resetHistoryNavigation?.() + + const cursorPos = target.selectionStart + + // Slash command picker (only when editing the command token: "/") + if (value.startsWith("/") && cursorPos >= 1) { + const firstWhitespaceIndex = value.slice(1).search(/\s/) + const tokenEnd = firstWhitespaceIndex === -1 ? value.length : firstWhitespaceIndex + 1 + + if (cursorPos <= tokenEnd) { + setPickerMode("command") + setAtPosition(0) + setSearchQuery(value.substring(1, cursorPos)) + setShowPicker(true) + return + } + } + + const textBeforeCursor = value.substring(0, cursorPos) + const lastAtIndex = textBeforeCursor.lastIndexOf("@") + + const previousAtPosition = atPosition() + + if (lastAtIndex === -1) { + setIgnoredAtPositions(new Set()) + } else if (previousAtPosition !== null && lastAtIndex !== previousAtPosition) { + setIgnoredAtPositions((prev) => { + const next = new Set(prev) + next.delete(previousAtPosition) + return next + }) + } + + if (lastAtIndex !== -1) { + const textAfterAt = value.substring(lastAtIndex + 1, cursorPos) + const hasSpace = textAfterAt.includes(" ") || textAfterAt.includes("\n") + + if (!hasSpace && cursorPos === lastAtIndex + textAfterAt.length + 1) { + if (!ignoredAtPositions().has(lastAtIndex)) { + setPickerMode("mention") + setAtPosition(lastAtIndex) + setSearchQuery(textAfterAt) + setShowPicker(true) + } + return + } + } + + setShowPicker(false) + setAtPosition(null) + } + + function handlePickerSelect(item: PickerItem) { + const textarea = options.getTextarea() + + if (item.type === "command") { + const name = item.command.name + const currentPrompt = options.prompt() + + const afterSlash = currentPrompt.slice(1) + const firstWhitespaceIndex = afterSlash.search(/\s/) + const tokenEnd = firstWhitespaceIndex === -1 ? currentPrompt.length : firstWhitespaceIndex + 1 + + const before = "" + const after = currentPrompt.substring(tokenEnd) + const newPrompt = before + `/${name} ` + after + options.setPrompt(newPrompt) + + setTimeout(() => { + const nextTextarea = options.getTextarea() + if (nextTextarea) { + const newCursorPos = `/${name} `.length + nextTextarea.setSelectionRange(newCursorPos, newCursorPos) + nextTextarea.focus() + } + }, 0) + } else if (item.type === "agent") { + const agentName = item.agent.name + const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) + const alreadyAttached = existingAttachments.some( + (att) => att.source.type === "agent" && att.source.name === agentName, + ) + + if (!alreadyAttached) { + const attachment = createAgentAttachment(agentName) + addAttachment(options.instanceId(), options.sessionId(), attachment) + } + + const currentPrompt = options.prompt() + const pos = atPosition() + const cursorPos = textarea?.selectionStart || 0 + + if (pos !== null) { + const before = currentPrompt.substring(0, pos) + const after = currentPrompt.substring(cursorPos) + const attachmentText = `@${agentName}` + const newPrompt = before + attachmentText + " " + after + options.setPrompt(newPrompt) + + setTimeout(() => { + const nextTextarea = options.getTextarea() + if (nextTextarea) { + const newCursorPos = pos + attachmentText.length + 1 + nextTextarea.setSelectionRange(newCursorPos, newCursorPos) + } + }, 0) + } + } else if (item.type === "file") { + const displayPath = item.file.path + const relativePath = item.file.relativePath ?? displayPath + const isFolder = item.file.isDirectory ?? displayPath.endsWith("/") + + if (isFolder) { + const currentPrompt = options.prompt() + const pos = atPosition() + const cursorPos = textarea?.selectionStart || 0 + const folderMention = + relativePath === "." || relativePath === "" + ? "/" + : relativePath.replace(/\/+$/, "") + "/" + + if (pos !== null) { + const before = currentPrompt.substring(0, pos + 1) + const after = currentPrompt.substring(cursorPos) + const newPrompt = before + folderMention + after + options.setPrompt(newPrompt) + setSearchQuery(folderMention) + + setTimeout(() => { + const nextTextarea = options.getTextarea() + if (nextTextarea) { + const newCursorPos = pos + 1 + folderMention.length + nextTextarea.setSelectionRange(newCursorPos, newCursorPos) + } + }, 0) + } + + return + } + + const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath + const pathSegments = normalizedPath.split("/") + const filename = (() => { + const candidate = pathSegments[pathSegments.length - 1] || normalizedPath + return candidate === "." ? "/" : candidate + })() + + const existingAttachments = getAttachments(options.instanceId(), options.sessionId()) + const alreadyAttached = existingAttachments.some( + (att) => att.source.type === "file" && att.source.path === normalizedPath, + ) + + if (!alreadyAttached) { + const attachment = createFileAttachment( + normalizedPath, + filename, + "text/plain", + undefined, + options.instanceFolder(), + ) + addAttachment(options.instanceId(), options.sessionId(), attachment) + } + + const currentPrompt = options.prompt() + const pos = atPosition() + const cursorPos = textarea?.selectionStart || 0 + + if (pos !== null) { + const before = currentPrompt.substring(0, pos) + const after = currentPrompt.substring(cursorPos) + const attachmentText = `@${normalizedPath}` + const newPrompt = before + attachmentText + " " + after + options.setPrompt(newPrompt) + + setTimeout(() => { + const nextTextarea = options.getTextarea() + if (nextTextarea) { + const newCursorPos = pos + attachmentText.length + 1 + nextTextarea.setSelectionRange(newCursorPos, newCursorPos) + } + }, 0) + } + } + + setShowPicker(false) + setAtPosition(null) + setSearchQuery("") + textarea?.focus() + } + + function handlePickerClose() { + const pos = atPosition() + if (pickerMode() === "mention" && pos !== null) { + setIgnoredAtPositions((prev) => new Set(prev).add(pos)) + } + setShowPicker(false) + setAtPosition(null) + setSearchQuery("") + setTimeout(() => options.getTextarea()?.focus(), 0) + } + + return { + showPicker, + pickerMode, + searchQuery, + atPosition, + ignoredAtPositions, + + setShowPicker, + setPickerMode, + setSearchQuery, + setAtPosition, + setIgnoredAtPositions, + + handleInput, + handlePickerSelect, + handlePickerClose, + } +} diff --git a/packages/ui/src/components/prompt-input/usePromptState.ts b/packages/ui/src/components/prompt-input/usePromptState.ts new file mode 100644 index 00000000..3ca38612 --- /dev/null +++ b/packages/ui/src/components/prompt-input/usePromptState.ts @@ -0,0 +1,193 @@ +import { createEffect, createSignal, on, onCleanup, onMount, type Accessor } from "solid-js" +import { addToHistory, getHistory } from "../../stores/message-history" +import { clearSessionDraftPrompt, getSessionDraftPrompt, setSessionDraftPrompt } from "../../stores/sessions" +import { getLogger } from "../../lib/logger" + +const log = getLogger("actions") + +type GetTextarea = () => HTMLTextAreaElement | undefined | null + +type PromptStateOptions = { + instanceId: Accessor + sessionId: Accessor + instanceFolder: Accessor + onSessionDraftLoaded?: (draft: string) => void +} + +type HistorySelectOptions = { + force?: boolean + isPickerOpen: boolean + getTextarea: GetTextarea +} + +type PromptState = { + prompt: Accessor + setPrompt: (value: string) => void + clearPrompt: () => void + + draftLoadedNonce: Accessor + + history: Accessor + historyIndex: Accessor + historyDraft: Accessor + + resetHistoryNavigation: () => void + clearHistoryDraft: () => void + recordHistoryEntry: (entry: string) => Promise + + selectPreviousHistory: (options: HistorySelectOptions) => boolean + selectNextHistory: (options: HistorySelectOptions) => boolean +} + +const HISTORY_LIMIT = 100 + +export function usePromptState(options: PromptStateOptions): PromptState { + const [prompt, setPromptInternal] = createSignal("") + const [history, setHistory] = createSignal([]) + const [historyIndex, setHistoryIndex] = createSignal(-1) + const [historyDraft, setHistoryDraft] = createSignal(null) + const [draftLoadedNonce, setDraftLoadedNonce] = createSignal(0) + + const setPrompt = (value: string) => { + setPromptInternal(value) + setSessionDraftPrompt(options.instanceId(), options.sessionId(), value) + } + + const clearPrompt = () => { + clearSessionDraftPrompt(options.instanceId(), options.sessionId()) + setPromptInternal("") + } + + const resetHistoryNavigation = () => { + setHistoryIndex(-1) + setHistoryDraft(null) + } + + const clearHistoryDraft = () => { + setHistoryDraft(null) + } + + createEffect( + on( + () => `${options.instanceId()}:${options.sessionId()}`, + () => { + const instanceId = options.instanceId() + const sessionId = options.sessionId() + + onCleanup(() => { + // Persist the previous session's draft when switching sessions. + setSessionDraftPrompt(instanceId, sessionId, prompt()) + }) + + const storedPrompt = getSessionDraftPrompt(instanceId, sessionId) + + setPromptInternal(storedPrompt) + setSessionDraftPrompt(instanceId, sessionId, storedPrompt) + + resetHistoryNavigation() + + setDraftLoadedNonce((prev) => prev + 1) + options.onSessionDraftLoaded?.(storedPrompt) + }, + ), + ) + + onMount(() => { + void (async () => { + const loaded = await getHistory(options.instanceFolder()) + setHistory(loaded) + })() + }) + + const recordHistoryEntry = async (entry: string) => { + try { + await addToHistory(options.instanceFolder(), entry) + setHistory((prev) => { + const next = [entry, ...prev] + if (next.length > HISTORY_LIMIT) { + next.length = HISTORY_LIMIT + } + return next + }) + setHistoryIndex(-1) + } catch (historyError) { + log.error("Failed to update prompt history:", historyError) + } + } + + const canUseHistory = (selectOptions: HistorySelectOptions) => { + if (selectOptions.force) return true + if (selectOptions.isPickerOpen) return false + + const textarea = selectOptions.getTextarea() + if (!textarea) return false + return textarea.selectionStart === 0 && textarea.selectionEnd === 0 + } + + const focusTextareaEnd = (getTextarea: GetTextarea) => { + const textarea = getTextarea() + if (!textarea) return + setTimeout(() => { + const next = getTextarea() + if (!next) return + const pos = next.value.length + next.setSelectionRange(pos, pos) + next.focus() + }, 0) + } + + const selectPreviousHistory = (selectOptions: HistorySelectOptions) => { + const entries = history() + if (entries.length === 0) return false + if (!canUseHistory(selectOptions)) return false + + if (historyIndex() === -1) { + setHistoryDraft(prompt()) + } + + const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, entries.length - 1) + setHistoryIndex(newIndex) + setPrompt(entries[newIndex]) + focusTextareaEnd(selectOptions.getTextarea) + return true + } + + const selectNextHistory = (selectOptions: HistorySelectOptions) => { + const entries = history() + if (entries.length === 0) return false + if (!canUseHistory(selectOptions)) return false + if (historyIndex() === -1) return false + + const newIndex = historyIndex() - 1 + if (newIndex >= 0) { + setHistoryIndex(newIndex) + setPrompt(entries[newIndex]) + } else { + setHistoryIndex(-1) + const draft = historyDraft() + setPrompt(draft ?? "") + setHistoryDraft(null) + } + focusTextareaEnd(selectOptions.getTextarea) + return true + } + + return { + prompt, + setPrompt, + clearPrompt, + + draftLoadedNonce, + + history, + historyIndex, + historyDraft, + + resetHistoryNavigation, + clearHistoryDraft, + recordHistoryEntry, + + selectPreviousHistory, + selectNextHistory, + } +} diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index b75dd046..cce1b454 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -1,12 +1,11 @@ -import { Show, For, createMemo, createEffect, on, type Component } from "solid-js" -import { Expand } from "lucide-solid" +import { Show, createMemo, createEffect, on, type Component } from "solid-js" import type { Session } from "../../types/session" import type { Attachment } from "../../types/attachment" import type { ClientPart } from "../../types/message" import MessageSection from "../message-section" import { messageStoreBus } from "../../stores/message-v2/bus" import PromptInput from "../prompt-input" -import type { Attachment as PromptAttachment } from "../../types/attachment" +import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar" import { getAttachments, removeAttachment } from "../../stores/attachments" import { instances } from "../../stores/instances" import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions" @@ -15,6 +14,7 @@ import { showAlertDialog } from "../../stores/alerts" import { getLogger } from "../../lib/logger" import { requestData } from "../../lib/opencode-api" import { useI18n } from "../../lib/i18n" +import type { PromptInputApi, PromptInsertMode } from "../prompt-input/types" const log = getLogger("session") @@ -53,52 +53,9 @@ export const SessionView: Component = (props) => { const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId)) - function handleExpandTextAttachment(attachment: PromptAttachment) { - if (attachment.source.type !== "text") return - - const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | null - const value = attachment.source.value - const match = attachment.display.match(/pasted #(\d+)/) - const placeholder = match ? `[pasted #${match[1]}]` : null - - const currentText = textarea?.value ?? "" - - let nextText = currentText - let selectionTarget: number | null = null - - if (placeholder) { - const placeholderIndex = currentText.indexOf(placeholder) - if (placeholderIndex !== -1) { - nextText = - currentText.substring(0, placeholderIndex) + - value + - currentText.substring(placeholderIndex + placeholder.length) - selectionTarget = placeholderIndex + value.length - } - } - - if (nextText === currentText) { - if (textarea) { - const start = textarea.selectionStart - const end = textarea.selectionEnd - nextText = currentText.substring(0, start) + value + currentText.substring(end) - selectionTarget = start + value.length - } else { - nextText = currentText + value - } - } - - if (textarea) { - textarea.value = nextText - textarea.dispatchEvent(new Event("input", { bubbles: true })) - textarea.focus() - if (selectionTarget !== null) { - textarea.setSelectionRange(selectionTarget, selectionTarget) - } - } - - removeAttachment(props.instanceId, props.sessionId, attachment.id) - } + let promptInputApi: PromptInputApi | null = null + let pendingPromptText: string | null = null + let pendingSelectionInsert: { text: string; mode: PromptInsertMode } | null = null let scrollToBottomHandle: (() => void) | undefined let rootRef: HTMLDivElement | undefined @@ -135,6 +92,11 @@ export const SessionView: Component = (props) => { // Defer until the session pane is visible and the textarea is mounted. requestAnimationFrame(() => { requestAnimationFrame(() => { + if (promptInputApi) { + promptInputApi.focus() + return + } + const textarea = rootRef?.querySelector(".prompt-input") if (!textarea) return if (textarea.disabled) return @@ -149,8 +111,7 @@ export const SessionView: Component = (props) => { }, ), ) - let quoteHandler: ((text: string, mode: "quote" | "code") => void) | null = null - + createEffect(() => { const currentSession = session() if (currentSession) { @@ -158,18 +119,31 @@ export const SessionView: Component = (props) => { } }) - function registerQuoteHandler(handler: (text: string, mode: "quote" | "code") => void) { - quoteHandler = handler + function registerPromptInputApi(api: PromptInputApi) { + promptInputApi = api + + if (pendingPromptText) { + api.setPromptText(pendingPromptText, { focus: true }) + pendingPromptText = null + } + + if (pendingSelectionInsert) { + api.insertSelection(pendingSelectionInsert.text, pendingSelectionInsert.mode) + pendingSelectionInsert = null + } + return () => { - if (quoteHandler === handler) { - quoteHandler = null + if (promptInputApi === api) { + promptInputApi = null } } } - function handleQuoteSelection(text: string, mode: "quote" | "code") { - if (quoteHandler) { - quoteHandler(text, mode) + function handleQuoteSelection(text: string, mode: PromptInsertMode) { + if (promptInputApi) { + promptInputApi.insertSelection(text, mode) + } else { + pendingSelectionInsert = { text, mode } } } @@ -230,14 +204,13 @@ export const SessionView: Component = (props) => { ) const restoredText = getUserMessageText(messageId) - if (restoredText) { - const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | undefined - if (textarea) { - textarea.value = restoredText - textarea.dispatchEvent(new Event("input", { bubbles: true })) - textarea.focus() - } - } + if (restoredText) { + if (promptInputApi) { + promptInputApi.setPromptText(restoredText, { focus: true }) + } else { + pendingPromptText = restoredText + } + } } catch (error) { log.error("Failed to revert message", error) showAlertDialog(t("sessionView.alerts.revertFailed.message"), { @@ -271,14 +244,13 @@ export const SessionView: Component = (props) => { await loadMessages(props.instanceId, forkedSession.id).catch((error) => log.error("Failed to load forked session messages", error)) - if (restoredText) { - const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | undefined - if (textarea) { - textarea.value = restoredText - textarea.dispatchEvent(new Event("input", { bubbles: true })) - textarea.focus() - } - } + if (restoredText) { + if (promptInputApi) { + promptInputApi.setPromptText(restoredText, { focus: true }) + } else { + pendingPromptText = restoredText + } + } } catch (error) { log.error("Failed to fork session", error) showAlertDialog(t("sessionView.alerts.forkFailed.message"), { @@ -327,39 +299,13 @@ export const SessionView: Component = (props) => { /> - 0}> -
- - {(attachment) => { - const isText = attachment.source.type === "text" - return ( -
- {attachment.display} - - - - -
- ) - }} -
-
-
+ 0}> + removeAttachment(props.instanceId, props.sessionId, attachmentId)} + onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)} + /> + = (props) => { isSessionBusy={sessionBusy()} disabled={sessionNeedsInput()} onAbortSession={handleAbortSession} - registerQuoteHandler={registerQuoteHandler} - /> - - ) - }} + registerPromptInputApi={registerPromptInputApi} + /> + + ) + }} ) } From d34e0163e38c6b38f6cd7fa031442968c552ad06 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 11 Feb 2026 10:51:27 +0000 Subject: [PATCH 25/35] fix(ui): keep right panel layout in empty states Render SplitFilePanel consistently and move empty/loading messages into the viewer area so the right drawer keeps its standard layout even when there are no session diffs, no git changes, or files are still loading. --- .../shell/right-panel/tabs/ChangesTab.tsx | 152 +++++++++--------- .../shell/right-panel/tabs/FilesTab.tsx | 22 +-- .../shell/right-panel/tabs/GitChangesTab.tsx | 150 +++++++++-------- 3 files changed, 160 insertions(+), 164 deletions(-) diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx index 08109ead..25a36824 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx @@ -32,32 +32,11 @@ interface ChangesTabProps { const ChangesTab: Component = (props) => { const renderContent = (): JSX.Element => { const sessionId = props.activeSessionId() - if (!sessionId || sessionId === "info") { - return ( -
- {props.t("instanceShell.sessionChanges.noSessionSelected")} -
- ) - } - const diffs = props.activeSessionDiffs() - if (diffs === undefined) { - return ( -
- {props.t("instanceShell.sessionChanges.loading")} -
- ) - } + const hasSession = Boolean(sessionId && sessionId !== "info") + const diffs = hasSession ? props.activeSessionDiffs() : null - if (!Array.isArray(diffs) || diffs.length === 0) { - return ( -
- {props.t("instanceShell.sessionChanges.empty")} -
- ) - } - - const sorted = [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) + const sorted = Array.isArray(diffs) ? [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) : [] const totals = sorted.reduce( (acc, item) => { acc.additions += typeof item.additions === "number" ? item.additions : 0 @@ -67,25 +46,27 @@ const ChangesTab: Component = (props) => { { additions: 0, deletions: 0 }, ) - const mostChanged = sorted.reduce((best, item) => { - const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0 - const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0 - const bestScore = bestAdd + bestDel + const mostChanged = sorted.length + ? sorted.reduce((best, item) => { + const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0 + const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0 + const bestScore = bestAdd + bestDel - const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0 - const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0 - const score = add + del + const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0 + const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0 + const score = add + del - if (score > bestScore) return item - if (score < bestScore) return best - return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best - }, sorted[0]) + if (score > bestScore) return item + if (score < bestScore) return best + return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best + }, sorted[0]) + : null // Auto-select the most-changed file if none selected. const currentSelected = props.selectedFile() const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged - const scopeKey = `${props.instanceId}:${sessionId}` + const scopeKey = `${props.instanceId}:${hasSession ? sessionId : "no-session"}` const isBinaryDiff = (item: any) => { const before = typeof item?.before === "string" ? item.before : "" @@ -97,6 +78,13 @@ const ChangesTab: Component = (props) => { return false } + const emptyViewerMessage = () => { + if (!hasSession) return props.t("instanceShell.sessionChanges.noSessionSelected") + if (diffs === undefined) return props.t("instanceShell.sessionChanges.loading") + if (!Array.isArray(diffs) || diffs.length === 0) return props.t("instanceShell.sessionChanges.empty") + return props.t("instanceShell.filesShell.viewerEmpty") + } + const renderViewer = () => (
@@ -109,10 +97,10 @@ const ChangesTab: Component = (props) => {
0} fallback={
- {props.t("instanceShell.filesShell.viewerEmpty")} + {emptyViewerMessage()}
} > @@ -140,59 +128,69 @@ const ChangesTab: Component = (props) => {
) + const renderEmptyList = () => ( +
{emptyViewerMessage()}
+ ) + const renderListPanel = () => ( - - {(item) => ( -
{ - props.onSelectFile(item.file, props.isPhoneLayout()) - }} - > -
-
- {item.file} -
-
- +{item.additions} - -{item.deletions} + 0} fallback={renderEmptyList()}> + + {(item) => ( +
{ + props.onSelectFile(item.file, props.isPhoneLayout()) + }} + > +
+
+ {item.file} +
+
+ +{item.additions} + -{item.deletions} +
-
- )} - + )} + + ) const renderListOverlay = () => ( - - {(item) => ( -
{ - props.onSelectFile(item.file, true) - }} - title={item.file} - > -
-
- {item.file} -
-
- +{item.additions} - -{item.deletions} + 0} fallback={renderEmptyList()}> + + {(item) => ( +
{ + props.onSelectFile(item.file, true) + }} + title={item.file} + > +
+
+ {item.file} +
+
+ +{item.additions} + -{item.deletions} +
-
- )} - + )} + + ) + const headerPath = () => (selectedFileData?.file ? selectedFileData.file : props.t("instanceShell.rightPanel.tabs.changes")) + return ( - - {selectedFileData?.file || ""} + + {headerPath()}
diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx index dcba9267..73dd56cb 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx @@ -37,15 +37,8 @@ interface FilesTabProps { const FilesTab: Component = (props) => { const renderContent = (): JSX.Element => { - if (props.browserLoading() && props.browserEntries() === null) { - return ( -
- Loading files... -
- ) - } - - const entries = props.browserEntries() || [] + const entriesValue = props.browserEntries() + const entries = entriesValue || [] const sorted = [...entries].sort((a, b) => { const aDir = a.type === "directory" ? 0 : 1 const bDir = b.type === "directory" ? 0 : 1 @@ -57,6 +50,11 @@ const FilesTab: Component = (props) => { const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath() + const emptyViewerMessage = () => { + if (props.browserLoading() && entriesValue === null) return "Loading files..." + return "Select a file to preview" + } + const renderViewer = () => (
@@ -74,7 +72,7 @@ const FilesTab: Component = (props) => { } fallback={
- Select a file to preview + {emptyViewerMessage()}
} > @@ -114,6 +112,10 @@ const FilesTab: Component = (props) => { )} + +
Loading files...
+
+ {(item) => (
= (props) => { const renderContent = (): JSX.Element => { const sessionId = props.activeSessionId() - if (!sessionId || sessionId === "info") { - return ( -
- Select a session to view changes. -
- ) - } - const entries = props.entries() - if (entries === null) { - return ( -
- Loading git changes… -
- ) - } + const hasSession = Boolean(sessionId && sessionId !== "info") + const entries = hasSession ? props.entries() : null - const nonDeleted = entries.filter((item) => item && item.status !== "deleted") - if (nonDeleted.length === 0) { - return ( -
- No git changes yet. -
- ) - } + const sorted = Array.isArray(entries) + ? [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || ""))) + : [] - const sorted = [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || ""))) const totals = sorted.reduce( (acc, item) => { acc.additions += typeof item.added === "number" ? item.added : 0 @@ -82,6 +63,15 @@ const GitChangesTab: Component = (props) => { { additions: 0, deletions: 0 }, ) + const nonDeleted = sorted.filter((item) => item && item.status !== "deleted") + + const emptyViewerMessage = () => { + if (!hasSession) return "Select a session to view changes." + if (entries === null) return "Loading git changes…" + if (nonDeleted.length === 0) return "No git changes yet." + return "No file selected." + } + const selectedPath = props.selectedPath() const fallbackPath = props.mostChangedPath() const selectedEntry = @@ -120,7 +110,7 @@ const GitChangesTab: Component = (props) => { } fallback={
- No file selected. + {emptyViewerMessage()}
} > @@ -153,71 +143,77 @@ const GitChangesTab: Component = (props) => {
) + const renderEmptyList = () =>
{emptyViewerMessage()}
+ const renderListPanel = () => ( - - {(item) => ( -
{ - props.onOpenFile(item.path) - }} - > -
-
- {item.path} -
-
- - deleted - - - <> - +{item.added} - -{item.removed} - - + 0} fallback={renderEmptyList()}> + + {(item) => ( +
{ + props.onOpenFile(item.path) + }} + > +
+
+ {item.path} +
+
+ + deleted + + + <> + +{item.added} + -{item.removed} + + +
-
- )} - + )} + + ) const renderListOverlay = () => ( - - {(item) => ( -
props.onOpenFile(item.path)} - title={item.path} - > -
-
- {item.path} -
-
- - deleted - - - <> - +{item.added} - -{item.removed} - - + 0} fallback={renderEmptyList()}> + + {(item) => ( +
props.onOpenFile(item.path)} + title={item.path} + > +
+
+ {item.path} +
+
+ + deleted + + + <> + +{item.added} + -{item.removed} + + +
-
- )} - + )} + + ) return ( - - {selectedEntry?.path || ""} + + {selectedEntry?.path || "Git Changes"}
@@ -235,7 +231,7 @@ const GitChangesTab: Component = (props) => { class="files-header-icon-button" title={props.t("instanceShell.rightPanel.actions.refresh")} aria-label={props.t("instanceShell.rightPanel.actions.refresh")} - disabled={props.statusLoading()} + disabled={!hasSession || props.statusLoading() || entries === null} style={{ "margin-left": "auto" }} onClick={() => props.onRefresh()} > From 204b2e020bdb396c3b76cfe1a48dd8f2c70a3522 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 11 Feb 2026 10:55:57 +0000 Subject: [PATCH 26/35] docs: document i18n conventions for agents --- AGENTS.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index e9759839..89d70ac4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,23 @@ - Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state. - When adding platform integrations (SSE, IPC, SDK), isolate them in thin adapters that surface typed events/actions. +## Multi-Language Support (i18n) + +The UI uses a small custom i18n layer (no ICU/messageformat). When building features, never hardcode user-visible strings. + +- **Runtime API:** use `useI18n()` in components (`const { t } = useI18n();`) and `tGlobal(...)` in stores/non-component code. + - Implementation: `packages/ui/src/lib/i18n/index.tsx` +- **Where messages live:** `packages/ui/src/lib/i18n/messages//` as TypeScript objects (`"flat.dot.keys": "string"`). + - Each locale has an `index.ts` that merges message parts; duplicate keys throw at build time. + - Merge helper: `packages/ui/src/lib/i18n/messages/merge.ts` +- **Adding a new string:** add it to the appropriate `.../messages/en/*.ts` part file, then add the same key to each other locale’s corresponding file. + - Missing translations fall back to English (and finally to the key), so gaps can be easy to miss. +- **Interpolation:** placeholders are simple `{name}` replacements (word characters only). Avoid placeholders like `{file-name}`. +- **Pluralization:** handle manually via separate keys like `something.one` / `something.other` and choose in code. +- **Adding a new language:** add a new `messages//` folder + `index.ts`, register it in `packages/ui/src/lib/i18n/index.tsx`, and add it to the language picker in `packages/ui/src/components/folder-selection-view.tsx`. +- **Locale persistence:** the selected locale is stored in app preferences (`locale`) and persisted via the server config (default `~/.config/codenomad/config.json`). +- **Avoid English-only paths:** do not import `enMessages` directly in feature code; always go through `t(...)` so locale changes apply. + ## File Length Guidelines (Highlight Only) We track file size as a refactoring signal. When you touch or create files, highlight oversized files so the team can plan refactors when time permits. From fd499d95e6cc16d39c400ab6a60eb431b06e7acc Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 11 Feb 2026 11:27:24 +0000 Subject: [PATCH 27/35] fix(ui): truncate right panel paths from start Use RTL ellipsis with bidi isolation so long paths keep the filename visible. --- .../shell/right-panel/tabs/ChangesTab.tsx | 6 +++--- .../instance/shell/right-panel/tabs/FilesTab.tsx | 6 +++--- .../shell/right-panel/tabs/GitChangesTab.tsx | 6 +++--- packages/ui/src/styles/panels/right-panel.css | 16 ++++++++++++++-- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx index 25a36824..2a038531 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx @@ -144,7 +144,7 @@ const ChangesTab: Component = (props) => { >
- {item.file} + {item.file}
+{item.additions} @@ -170,7 +170,7 @@ const ChangesTab: Component = (props) => { >
- {item.file} + {item.file}
+{item.additions} @@ -190,7 +190,7 @@ const ChangesTab: Component = (props) => { header={ <> - {headerPath()} + {headerPath()}
diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx index 73dd56cb..dc462474 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx @@ -105,7 +105,7 @@ const FilesTab: Component = (props) => {
props.onLoadEntries(p())}>
- .. + ..
@@ -131,7 +131,7 @@ const FilesTab: Component = (props) => { >
- {item.name} + {item.name}
{item.type} @@ -150,7 +150,7 @@ const FilesTab: Component = (props) => {
- {headerDisplayedPath()} + {headerDisplayedPath()} diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx index ba9a53b9..bd77d7e5 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx @@ -157,7 +157,7 @@ const GitChangesTab: Component = (props) => { >
- {item.path} + {item.path}
@@ -188,7 +188,7 @@ const GitChangesTab: Component = (props) => { >
- {item.path} + {item.path}
@@ -213,7 +213,7 @@ const GitChangesTab: Component = (props) => { header={ <> - {selectedEntry?.path || "Git Changes"} + {selectedEntry?.path || "Git Changes"}
diff --git a/packages/ui/src/styles/panels/right-panel.css b/packages/ui/src/styles/panels/right-panel.css index 58d78537..f2bfb016 100644 --- a/packages/ui/src/styles/panels/right-panel.css +++ b/packages/ui/src/styles/panels/right-panel.css @@ -164,9 +164,15 @@ @apply text-xs font-mono min-w-0 flex-1 overflow-hidden whitespace-nowrap; color: var(--text-primary); text-overflow: ellipsis; + /* Truncate from the start; keep filename visible. */ direction: rtl; text-align: left; - unicode-bidi: plaintext; + unicode-bidi: isolate; +} + +.files-tab-selected-path .file-path-text { + direction: ltr; + unicode-bidi: isolate; } .files-tab-stat { @@ -240,9 +246,15 @@ @apply text-xs font-mono min-w-0 flex-1 overflow-hidden whitespace-nowrap; color: var(--text-primary); text-overflow: ellipsis; + /* Truncate from the start; keep filename visible. */ direction: rtl; text-align: left; - unicode-bidi: plaintext; + unicode-bidi: isolate; +} + +.file-list-item-path .file-path-text { + direction: ltr; + unicode-bidi: isolate; } .file-list-item-stats { From 5bfb09c73bf878b8c30735f01add5105f165af25 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 11 Feb 2026 11:53:27 +0000 Subject: [PATCH 28/35] fix(ui): Fix gutter for Monaco --- .../ui/src/components/file-viewer/monaco-diff-viewer.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx index 83684db8..6c6b71de 100644 --- a/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx +++ b/packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx @@ -49,11 +49,17 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { automaticLayout: true, renderSideBySide: true, renderSideBySideInlineBreakpoint: 0, + renderMarginRevertIcon: false, minimap: { enabled: false }, scrollBeyondLastLine: false, renderWhitespace: "selection", fontSize: 13, wordWrap: "off", + glyphMargin: false, + folding: false, + // Keep enough gutter space so unified diffs don't overlap `+`/`-` markers. + lineNumbersMinChars: 4, + lineDecorationsWidth: 12, }) setReady(true) From d7c4bf1e456c289c1eb47bba3208d43bd6766c15 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 11 Feb 2026 12:31:09 +0000 Subject: [PATCH 29/35] fix(ui): render selected session diff payload Pass the selected diff object through Solid's Show so MonacoDiffViewer receives before/after content. --- .../shell/right-panel/tabs/ChangesTab.tsx | 37 +++++-------------- 1 file changed, 9 insertions(+), 28 deletions(-) diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx index 2a038531..9821d3c5 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx @@ -68,16 +68,6 @@ const ChangesTab: Component = (props) => { const scopeKey = `${props.instanceId}:${hasSession ? sessionId : "no-session"}` - const isBinaryDiff = (item: any) => { - const before = typeof item?.before === "string" ? item.before : "" - const after = typeof item?.after === "string" ? item.after : "" - if (before.length === 0 && after.length === 0) { - // OpenCode stores empty before/after for binaries. - return true - } - return false - } - const emptyViewerMessage = () => { if (!hasSession) return props.t("instanceShell.sessionChanges.noSessionSelected") if (diffs === undefined) return props.t("instanceShell.sessionChanges.loading") @@ -97,7 +87,7 @@ const ChangesTab: Component = (props) => {
0} + when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null} fallback={
{emptyViewerMessage()} @@ -105,23 +95,14 @@ const ChangesTab: Component = (props) => { } > {(file) => ( - - Binary file cannot be displayed -
- } - > - -
+ )}
From 2c2440274280b59b35b59fce5104d367df8707b7 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 11 Feb 2026 13:16:23 +0000 Subject: [PATCH 30/35] Bump v0.10.3 and min server 0.10.3 --- package-lock.json | 12 ++++++------ package.json | 2 +- packages/cloudflare/release-config.json | 2 +- packages/electron-app/package.json | 2 +- packages/server/package-lock.json | 4 ++-- packages/server/package.json | 2 +- packages/tauri-app/package.json | 2 +- packages/ui/package.json | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9ee26ffd..b0157bb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codenomad-workspace", - "version": "0.10.2", + "version": "0.10.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codenomad-workspace", - "version": "0.10.2", + "version": "0.10.3", "license": "MIT", "dependencies": { "7zip-bin": "^5.2.0", @@ -11970,7 +11970,7 @@ }, "packages/electron-app": { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.10.2", + "version": "0.10.3", "license": "MIT", "dependencies": { "@codenomad/ui": "file:../ui", @@ -12005,7 +12005,7 @@ }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.10.2", + "version": "0.10.3", "license": "MIT", "dependencies": { "@fastify/cors": "^8.5.0", @@ -12045,7 +12045,7 @@ }, "packages/tauri-app": { "name": "@codenomad/tauri-app", - "version": "0.10.2", + "version": "0.10.3", "license": "MIT", "devDependencies": { "@tauri-apps/cli": "^2.9.4" @@ -12053,7 +12053,7 @@ }, "packages/ui": { "name": "@codenomad/ui", - "version": "0.10.2", + "version": "0.10.3", "license": "MIT", "dependencies": { "@git-diff-view/solid": "^0.0.8", diff --git a/package.json b/package.json index a116049b..e93f8d99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.10.2", + "version": "0.10.3", "private": true, "description": "CodeNomad monorepo workspace", "license": "MIT", diff --git a/packages/cloudflare/release-config.json b/packages/cloudflare/release-config.json index 5ff3c319..5cae1b9d 100644 --- a/packages/cloudflare/release-config.json +++ b/packages/cloudflare/release-config.json @@ -1,4 +1,4 @@ { - "minServerVersion": "0.10.2", + "minServerVersion": "0.10.3", "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" } diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index c7874888..37945ffe 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.10.2", + "version": "0.10.3", "description": "CodeNomad - AI coding assistant", "license": "MIT", "author": { diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index 98c265a7..b46ae731 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuralnomads/codenomad", - "version": "0.10.2", + "version": "0.10.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuralnomads/codenomad", - "version": "0.10.2", + "version": "0.10.3", "dependencies": { "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", diff --git a/packages/server/package.json b/packages/server/package.json index ef52a55e..5aad36da 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.10.2", + "version": "0.10.3", "description": "CodeNomad Server", "license": "MIT", "author": { diff --git a/packages/tauri-app/package.json b/packages/tauri-app/package.json index b24d0c4f..86b42d54 100644 --- a/packages/tauri-app/package.json +++ b/packages/tauri-app/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/tauri-app", - "version": "0.10.2", + "version": "0.10.3", "private": true, "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index f84eb5c4..83cbeb57 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.10.2", + "version": "0.10.3", "private": true, "license": "MIT", "type": "module", From 612e50808a68a9e4c6f81298bd0c9929303b98d6 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 11 Feb 2026 13:52:02 +0000 Subject: [PATCH 31/35] fix(ui): preserve draft across prompt history Stop resetting history navigation on input so editing recalled entries doesn't wipe the bottom draft. Allow ArrowDown navigation while in history and persist the session draft only for fresh prompts. --- packages/ui/src/components/prompt-input.tsx | 2 -- .../src/components/prompt-input/usePromptPicker.ts | 2 -- .../src/components/prompt-input/usePromptState.ts | 14 ++++++++++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index 8229b877..77ce1aeb 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -135,7 +135,6 @@ export default function PromptInput(props: PromptInputProps) { instanceFolder: () => props.instanceFolder, prompt, setPrompt, - resetHistoryNavigation, getTextarea: () => textareaRef ?? null, instanceAgents, commands: () => getCommands(props.instanceId), @@ -303,7 +302,6 @@ export default function PromptInput(props: PromptInputProps) { const nextValue = before + insertion + after setPrompt(nextValue) - resetHistoryNavigation() setShowPicker(false) setAtPosition(null) diff --git a/packages/ui/src/components/prompt-input/usePromptPicker.ts b/packages/ui/src/components/prompt-input/usePromptPicker.ts index 958f4a4c..ada32cc9 100644 --- a/packages/ui/src/components/prompt-input/usePromptPicker.ts +++ b/packages/ui/src/components/prompt-input/usePromptPicker.ts @@ -17,7 +17,6 @@ type PromptPickerOptions = { prompt: Accessor setPrompt: (value: string) => void - resetHistoryNavigation?: () => void getTextarea: () => HTMLTextAreaElement | null instanceAgents: Accessor @@ -53,7 +52,6 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr const target = e.target as HTMLTextAreaElement const value = target.value options.setPrompt(value) - options.resetHistoryNavigation?.() const cursorPos = target.selectionStart diff --git a/packages/ui/src/components/prompt-input/usePromptState.ts b/packages/ui/src/components/prompt-input/usePromptState.ts index 3ca38612..3b326f2c 100644 --- a/packages/ui/src/components/prompt-input/usePromptState.ts +++ b/packages/ui/src/components/prompt-input/usePromptState.ts @@ -50,7 +50,11 @@ export function usePromptState(options: PromptStateOptions): PromptState { const setPrompt = (value: string) => { setPromptInternal(value) - setSessionDraftPrompt(options.instanceId(), options.sessionId(), value) + // Persist drafts only when the user is at the "fresh" position (not browsing history). + // This keeps the bottom-of-history draft stable even if the user edits recalled history entries. + if (historyIndex() === -1) { + setSessionDraftPrompt(options.instanceId(), options.sessionId(), value) + } } const clearPrompt = () => { @@ -121,6 +125,12 @@ export function usePromptState(options: PromptStateOptions): PromptState { const textarea = selectOptions.getTextarea() if (!textarea) return false + + // Only require the cursor to be at the buffer start when *entering* history navigation. + // Once we're already navigating history (historyIndex >= 0), allow ArrowUp/ArrowDown + // regardless of cursor position (we focus the end of the entry). + if (historyIndex() !== -1) return true + return textarea.selectionStart === 0 && textarea.selectionEnd === 0 } @@ -164,7 +174,7 @@ export function usePromptState(options: PromptStateOptions): PromptState { setPrompt(entries[newIndex]) } else { setHistoryIndex(-1) - const draft = historyDraft() + const draft = historyDraft() ?? getSessionDraftPrompt(options.instanceId(), options.sessionId()) setPrompt(draft ?? "") setHistoryDraft(null) } From ea92c0609d394ca61f6781489402619b8905a4c3 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 11 Feb 2026 14:06:39 +0000 Subject: [PATCH 32/35] fix(server): move spawn env/args behind debug/trace (#141) --- packages/server/src/workspaces/runtime.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts index 06b6813b..a7196d60 100644 --- a/packages/server/src/workspaces/runtime.ts +++ b/packages/server/src/workspaces/runtime.ts @@ -116,12 +116,26 @@ export class WorkspaceRuntime { folder: options.folder, binary: options.binaryPath, spawnCommand: spec.command, - spawnArgs: spec.args, commandLine, - env: redactEnvironment(env), }, "Launching OpenCode process", ) + + this.logger.debug( + { + workspaceId: options.workspaceId, + spawnArgs: spec.args, + }, + "OpenCode spawn args", + ) + + this.logger.trace( + { + workspaceId: options.workspaceId, + env: redactEnvironment(env), + }, + "OpenCode spawn environment", + ) const detached = process.platform !== "win32" const child = spawn(spec.command, spec.args, { cwd: options.folder, From 9b76521a907b520db4b6c8f2338c74343bdf0089 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 11 Feb 2026 14:24:29 +0000 Subject: [PATCH 33/35] fix(ui): improve recent folders path display (#147) --- .../src/components/folder-selection-view.tsx | 63 +++++++++++++++++-- packages/ui/src/styles/utilities.css | 10 +++ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index a77e3547..281ea084 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -254,12 +254,63 @@ const FolderSelectionView: Component = (props) => { function getDisplayPath(path: string): string { + if (!path) return path + + // macOS: /Users//... if (path.startsWith("/Users/")) { return path.replace(/^\/Users\/[^/]+/, "~") } + + // Linux: /home//... + if (path.startsWith("/home/")) { + return path.replace(/^\/home\/[^/]+/, "~") + } + + // Windows: C:\Users\\... (and the forward-slash variant) + if (/^[A-Za-z]:\\Users\\/.test(path)) { + return path.replace(/^[A-Za-z]:\\Users\\[^\\]+/, "~") + } + if (/^[A-Za-z]:\/Users\//.test(path)) { + return path.replace(/^[A-Za-z]:\/Users\/[^/]+/, "~") + } + return path } + function looksLikeWindowsPath(value: string): boolean { + if (!value) return false + // Drive letter (C:\...) or UNC (\\server\share\...) + return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\[^\\]+\\[^\\]+/.test(value) + } + + function splitFolderPath(rawPath: string): { baseName: string; dirName: string } { + if (!rawPath) return { baseName: "", dirName: "" } + + const isWindows = looksLikeWindowsPath(rawPath) + const trimmed = rawPath.replace(/[\\/]+$/, "") + + // Root edge-cases ("/", "C:\\", "\\\\server\\share\\") + if (!trimmed) { + return { baseName: rawPath, dirName: "" } + } + + if (isWindows && /^[A-Za-z]:$/.test(trimmed)) { + return { baseName: `${trimmed}\\`, dirName: "" } + } + + const lastSlash = trimmed.lastIndexOf("/") + const lastBackslash = isWindows ? trimmed.lastIndexOf("\\") : -1 + const lastSep = Math.max(lastSlash, lastBackslash) + + if (lastSep < 0) { + return { baseName: trimmed, dirName: "" } + } + + const baseName = trimmed.slice(lastSep + 1) || trimmed + const dirName = trimmed.slice(0, lastSep) + return { baseName, dirName } + } + return ( <>
= (props) => {
- {folder.path.split("/").pop()} + {splitFolderPath(folder.path).baseName}
-
- {getDisplayPath(folder.path)} -
-
- {formatRelativeTime(folder.lastAccessed)} +
+ + {getDisplayPath(folder.path)} + + {formatRelativeTime(folder.lastAccessed)}
diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css index b7609c9a..230f9bd0 100644 --- a/packages/ui/src/styles/utilities.css +++ b/packages/ui/src/styles/utilities.css @@ -153,6 +153,16 @@ @apply opacity-50; } +/* Truncate from the start (keeps end visible; good for paths) */ +.truncate-start { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; + text-align: left; + unicode-bidi: plaintext; +} + /* Prevent iOS Safari auto-zoom on text input focus */ @media (pointer: coarse) { input[type="text"], From fd5941fb36f9c5dab98efd825d21c97ac9e2d845 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 11 Feb 2026 15:41:28 +0000 Subject: [PATCH 34/35] fix(ui): show active session status in header Fixes #139 --- .../components/instance/instance-shell2.tsx | 158 +++++++++++++----- 1 file changed, 114 insertions(+), 44 deletions(-) diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index f180d0f7..c16bd408 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -35,10 +35,13 @@ import { serverApi } from "../../lib/api-client" import { loadBackgroundProcesses } from "../../stores/background-processes" import { BackgroundProcessOutputDialog } from "../background-process-output-dialog" import { useI18n } from "../../lib/i18n" +import { getPermissionQueueLength, getQuestionQueueLength } from "../../stores/instances" import SessionSidebar from "./shell/SessionSidebar" import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests" import RightPanel from "./shell/right-panel/RightPanel" import { useDrawerChrome } from "./shell/useDrawerChrome" +import { getSessionStatus } from "../../stores/session-status" +import { ShieldAlert } from "lucide-solid" import type { LayoutMode } from "./shell/types" import { @@ -229,6 +232,57 @@ const InstanceShell2: Component = (props) => { return t("instanceShell.connection.unknown") } + const hasPendingRequests = createMemo(() => { + const permissions = getPermissionQueueLength(props.instance.id) + const questions = getQuestionQueueLength(props.instance.id) + return permissions + questions > 0 + }) + + const activeSessionStatusPill = createMemo(() => { + const activeSessionId = activeSessionIdForInstance() + if (!activeSessionId || activeSessionId === "info") return null + + const activeSession = activeSessionForInstance() + const needsPermission = Boolean(activeSession?.pendingPermission) + const needsQuestion = Boolean(activeSession?.pendingQuestion) + const needsInput = needsPermission || needsQuestion + + if (needsInput) { + return { + className: "session-permission", + text: needsPermission + ? t("sessionList.status.needsPermission") + : t("sessionList.status.needsInput"), + showAlertIcon: true, + } + } + + const status = getSessionStatus(props.instance.id, activeSessionId) + const text = + status === "working" + ? t("sessionList.status.working") + : status === "compacting" + ? t("sessionList.status.compacting") + : t("sessionList.status.idle") + + return { + className: `session-${status}`, + text, + showAlertIcon: false, + } + }) + + const renderActiveSessionStatusPill = () => { + const pill = activeSessionStatusPill() + if (!pill) return null + return ( + + {pill.showAlertIcon ? -
- setPermissionModalOpen(true)} - /> +
+ + setPermissionModalOpen(true)} + /> + +
+ +
+ +
= (props) => {
} > -
+
= (props) => { {formattedAvailableTokens()}
+ +
+ + setPermissionModalOpen(true)} + /> + +
-
- setPermissionModalOpen(true)} - /> +
+
+ +
-
-
-
- - - - {t("instanceShell.connection.connected")} - - - - - - {t("instanceShell.connection.connecting")} - - - - - - {t("instanceShell.connection.disconnected")} - +
+
+ + + + {t("instanceShell.connection.connected")} + + + + + + {t("instanceShell.connection.connecting")} + + + + + + {t("instanceShell.connection.disconnected")} + + +
+ + + {rightAppBarButtonIcon()} +
- - - {rightAppBarButtonIcon()} - -
From 3382736f05195cc2cfbdb28e027c1438d363aaed Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 11 Feb 2026 16:02:24 +0000 Subject: [PATCH 35/35] fix(ui): split message header into two rows Move assistant meta below speaker label and bump speaker label size. --- packages/ui/src/components/message-item.tsx | 132 ++++++++++-------- .../ui/src/styles/messaging/message-base.css | 13 ++ 2 files changed, 84 insertions(+), 61 deletions(-) diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index 7041d130..2f8eb26e 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -268,74 +268,84 @@ export default function MessageItem(props: MessageItemProps) { return (
-
- - {speakerLabel()} - - {(meta) => {meta()}} -
-
- -
- - - - - - - -
-
- -
- +
+
+ + {speakerLabel()} + +
- - {(partId) => ( +
+ +
+ - )} - -
-
- + + + + + +
+
+ +
+ + + + {(partId) => ( + + )} + +
+
+ +
+ + {(meta) => ( +
+ {meta()} +
+ )} +
+
diff --git a/packages/ui/src/styles/messaging/message-base.css b/packages/ui/src/styles/messaging/message-base.css index 97c84350..8064ae62 100644 --- a/packages/ui/src/styles/messaging/message-base.css +++ b/packages/ui/src/styles/messaging/message-base.css @@ -4,15 +4,28 @@ } .message-item-header { + @apply flex flex-col gap-0.5; +} + +.message-item-header-row { + @apply w-full; +} + +.message-item-header-row--top { @apply flex justify-between items-start gap-2.5; } +.message-item-header-row--bottom { + @apply flex items-start; +} + .message-speaker { @apply flex flex-col gap-0.5 text-xs; } .message-speaker-label { font-weight: var(--font-weight-semibold); + font-size: var(--font-size-sm); } .message-speaker-label[data-role="user"] {