From c21bf717bceb190b2703fd0ea633d7a22221b318 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 11 Jul 2026 19:43:08 -0500 Subject: [PATCH 01/13] refac --- cptr/frontend/src/app.css | 83 +++----- cptr/frontend/src/lib/apis/chat.ts | 12 ++ .../src/lib/components/BrowserPreview.svelte | 2 +- .../src/lib/components/ChromeBrowser.svelte | 2 +- .../src/lib/components/FileEditor.svelte | 21 +- .../frontend/src/lib/components/GitBar.svelte | 5 +- .../src/lib/components/KeyPill.svelte | 9 +- .../lib/components/Settings/Appearance.svelte | 24 +-- .../lib/components/Settings/Keyboard.svelte | 6 +- .../components/Settings/Notifications.svelte | 2 +- .../src/lib/components/ShortcutBar.svelte | 27 +-- .../src/lib/components/Sidebar.svelte | 2 +- .../components/SidebarWorkspaceList.svelte | 6 +- .../src/lib/components/Terminal.svelte | 47 +++-- .../automations/ScheduleDropdown.svelte | 25 +-- .../lib/components/chat/AskUserCard.svelte | 152 ++++++++++++++ .../components/chat/AssistantMessage.svelte | 41 +++- .../components/chat/ChatFilePreview.svelte | 30 +-- .../src/lib/components/chat/ChatPanel.svelte | 15 ++ .../lib/components/common/Pagination.svelte | 30 +-- .../components/markdown/EditorToolbar.svelte | 43 +--- .../components/markdown/MermaidBlock.svelte | 20 +- .../components/markdown/RichTextEditor.svelte | 53 ++--- .../lib/components/preview/CsvTable.svelte | 42 +--- .../components/preview/ImagePreview.svelte | 41 +--- .../components/preview/OfficePreview.svelte | 69 ++----- .../lib/components/preview/PDFViewer.svelte | 36 +--- .../lib/components/preview/SqliteView.svelte | 105 +++------- .../lib/components/preview/SvgPreview.svelte | 34 +--- cptr/frontend/src/routes/+page.svelte | 10 +- cptr/routers/chat.py | 87 ++++++++ cptr/utils/chat_task.py | 187 +++++++++++++++++- 32 files changed, 717 insertions(+), 551 deletions(-) create mode 100644 cptr/frontend/src/lib/components/chat/AskUserCard.svelte diff --git a/cptr/frontend/src/app.css b/cptr/frontend/src/app.css index bf7d6c4..c29a90c 100644 --- a/cptr/frontend/src/app.css +++ b/cptr/frontend/src/app.css @@ -8,19 +8,19 @@ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, ui-sans-serif, system-ui, sans-serif; --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; - /* Exact Open WebUI gray scale - pure neutral, zero chroma */ - --color-gray-50: oklch(0.98 0 0); - --color-gray-100: oklch(0.94 0 0); - --color-gray-200: oklch(0.92 0 0); - --color-gray-300: oklch(0.85 0 0); - --color-gray-400: oklch(0.77 0 0); - --color-gray-500: oklch(0.69 0 0); - --color-gray-600: oklch(0.51 0 0); - --color-gray-700: oklch(0.42 0 0); - --color-gray-800: oklch(0.32 0 0); - --color-gray-850: oklch(0.27 0 0); - --color-gray-900: oklch(0.2 0 0); - --color-gray-950: oklch(0.16 0 0); + /* Keep Tailwind's neutral utilities on the selected appearance. */ + --color-gray-50: color-mix(in oklab, var(--app-bg) 96%, var(--app-fg)); + --color-gray-100: color-mix(in oklab, var(--app-bg) 92%, var(--app-fg)); + --color-gray-200: color-mix(in oklab, var(--app-bg) 85%, var(--app-fg)); + --color-gray-300: color-mix(in oklab, var(--app-bg) 75%, var(--app-fg)); + --color-gray-400: color-mix(in oklab, var(--app-bg) 65%, var(--app-fg)); + --color-gray-500: color-mix(in oklab, var(--app-bg) 55%, var(--app-fg)); + --color-gray-600: color-mix(in oklab, var(--app-bg) 45%, var(--app-fg)); + --color-gray-700: color-mix(in oklab, var(--app-bg) 35%, var(--app-fg)); + --color-gray-800: color-mix(in oklab, var(--app-bg) 25%, var(--app-fg)); + --color-gray-850: color-mix(in oklab, var(--app-bg) 20%, var(--app-fg)); + --color-gray-900: color-mix(in oklab, var(--app-bg) 15%, var(--app-fg)); + --color-gray-950: color-mix(in oklab, var(--app-bg) 10%, var(--app-fg)); } :root { @@ -76,19 +76,12 @@ /* Scrollbar - Open WebUI style */ ::-webkit-scrollbar-thumb { - --tw-border-opacity: 1; - background-color: rgba(215, 215, 215, 0.6); - border-color: rgba(255, 255, 255, var(--tw-border-opacity)); + background-color: color-mix(in oklab, var(--app-fg) 32%, transparent); + border-color: var(--app-bg); border-radius: 624.9375rem; border-width: 1px; } -/* Dark theme scrollbar styles */ -.dark ::-webkit-scrollbar-thumb { - background-color: rgba(67, 67, 67, 0.6); - border-color: rgba(0, 0, 0, var(--tw-border-opacity)); -} - ::-webkit-scrollbar { height: 0.45rem; width: 0.45rem; @@ -293,20 +286,20 @@ } .dark .cm-editor { - background-color: #000 !important; + background-color: var(--app-bg) !important; } .dark .cm-gutters { - background-color: #000 !important; + background-color: var(--app-bg) !important; border: none !important; } .dark .cm-activeLine { - background-color: rgba(255, 255, 255, 0.04) !important; + background-color: var(--app-hover) !important; } .dark .cm-activeLineGutter { - background-color: rgba(255, 255, 255, 0.04) !important; + background-color: var(--app-hover) !important; } :not(.dark) .cm-gutters { @@ -338,45 +331,33 @@ .shimmer { background: linear-gradient( 110deg, - #b4b4b4 0%, - #b4b4b4 43%, - #e8e8e8 50%, - #b4b4b4 57%, - #b4b4b4 100% - ); - background-size: 200% 100%; - background-clip: text; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - animation: shimmer 1.5s cubic-bezier(0.7, 0, 1, 0.4) infinite; - color: #b4b4b4; -} - -.dark .shimmer { - background: linear-gradient( - 110deg, - #9a9a9a 0%, - #9a9a9a 43%, - #5e5e5e 50%, - #9a9a9a 57%, - #9a9a9a 100% + var(--app-fg-subtle) 0%, + var(--app-fg-subtle) 43%, + var(--app-fg) 50%, + var(--app-fg-subtle) 57%, + var(--app-fg-subtle) 100% ); background-size: 200% 100%; background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; animation: shimmer 1.5s cubic-bezier(0.7, 0, 1, 0.4) infinite; - color: #9a9a9a; + color: var(--app-fg-subtle); } /* Inline code (codespan), Open WebUI style */ .codespan { padding: 0.15rem 0.3rem; font-size: 0.85em; - @apply font-mono rounded-md text-gray-800 bg-gray-100 dark:text-gray-200 dark:bg-gray-800 mx-0.5; + @apply font-mono rounded-md mx-0.5; + background: var(--app-hover); + color: var(--app-fg); } /* Tippy tooltip theme */ .tippy-box[data-theme~='cptr'] { - @apply rounded-lg bg-gray-950 text-xs border border-gray-900 shadow-xl; + @apply rounded-lg text-xs border shadow-xl; + background: var(--app-bg); + color: var(--app-fg); + border-color: var(--app-border); } diff --git a/cptr/frontend/src/lib/apis/chat.ts b/cptr/frontend/src/lib/apis/chat.ts index 6914f37..4b9778a 100644 --- a/cptr/frontend/src/lib/apis/chat.ts +++ b/cptr/frontend/src/lib/apis/chat.ts @@ -147,6 +147,18 @@ export const approveToolCall = ( jsonBody({ call_id: callId, approved }) ); +export const answerAskUser = ( + chatId: string, + messageId: string, + callId: string, + answers: Record, + timedOut = false +) => + fetchJSON( + `/api/chats/${chatId}/messages/${messageId}/answer`, + jsonBody({ call_id: callId, answers, timed_out: timedOut }) + ); + export const cancelTask = (chatId: string, messageId: string) => fetchJSON(`/api/chats/${chatId}/messages/${messageId}/cancel`, { method: 'POST' }); diff --git a/cptr/frontend/src/lib/components/BrowserPreview.svelte b/cptr/frontend/src/lib/components/BrowserPreview.svelte index 7e4868b..12e4290 100644 --- a/cptr/frontend/src/lib/components/BrowserPreview.svelte +++ b/cptr/frontend/src/lib/components/BrowserPreview.svelte @@ -416,7 +416,7 @@ height: 100%; border: 0; display: block; - background: white; + background: var(--app-bg); } .preview-error { height: 100%; diff --git a/cptr/frontend/src/lib/components/ChromeBrowser.svelte b/cptr/frontend/src/lib/components/ChromeBrowser.svelte index aa0dc19..ea59939 100644 --- a/cptr/frontend/src/lib/components/ChromeBrowser.svelte +++ b/cptr/frontend/src/lib/components/ChromeBrowser.svelte @@ -699,7 +699,7 @@ width: 100%; height: 100%; display: block; - background: white; + background: var(--app-bg); } .chrome-container { position: relative; diff --git a/cptr/frontend/src/lib/components/FileEditor.svelte b/cptr/frontend/src/lib/components/FileEditor.svelte index da5a68e..bbdf9f7 100644 --- a/cptr/frontend/src/lib/components/FileEditor.svelte +++ b/cptr/frontend/src/lib/components/FileEditor.svelte @@ -955,16 +955,17 @@ '&': { height: '100%', fontSize: '0.8125rem', - backgroundColor: dark ? '#000' : '#ffffff' + backgroundColor: 'var(--app-bg)', + color: 'var(--app-fg)' }, '.cm-scroller': { fontFamily: '"JetBrains Mono", "Fira Code", ui-monospace, monospace' }, '.cm-gutters': { - backgroundColor: dark ? '#000' : '#ffffff', - color: dark ? '#555' : '#999', + backgroundColor: 'var(--app-bg)', + color: 'var(--app-fg-subtle)', border: 'none' }, - '.cm-activeLineGutter': { backgroundColor: dark ? '#1a1a1a' : '#f3f4f6' }, - '.cm-activeLine': { backgroundColor: dark ? '#1a1a1a' : '#f9fafb' }, + '.cm-activeLineGutter': { backgroundColor: 'var(--app-hover)' }, + '.cm-activeLine': { backgroundColor: 'var(--app-hover)' }, '.cm-lineNumbers .cm-gutterElement.cm-git-line-added': { boxShadow: `inset 0.125rem 0 0 ${dark ? 'rgba(52, 211, 153, 0.75)' : 'rgba(5, 150, 105, 0.65)'}` }, @@ -1306,8 +1307,8 @@ flex-shrink: 0; } - :global(.dark) .toolbar { - border-bottom-color: rgba(255, 255, 255, 0.06); + .toolbar { + border-bottom-color: var(--app-border); } .toolbar-left { @@ -1353,7 +1354,7 @@ } :global(.dark) .file-name { - color: var(--color-gray-300); + color: var(--app-fg); } .file-git-stats { @@ -1436,7 +1437,7 @@ } :global(.dark) .toolbar-btn.active { - color: white; + color: var(--app-fg); } .editor-area { @@ -1463,7 +1464,7 @@ max-width: 100%; max-height: 100%; border-radius: 0.5rem; - background: #000; + background: var(--app-bg); } .media-container audio { diff --git a/cptr/frontend/src/lib/components/GitBar.svelte b/cptr/frontend/src/lib/components/GitBar.svelte index e7160fd..fa8ab00 100644 --- a/cptr/frontend/src/lib/components/GitBar.svelte +++ b/cptr/frontend/src/lib/components/GitBar.svelte @@ -1498,7 +1498,8 @@ {#each fileDiff as df} {#if fileDiff.length > 1}
{df.path}
@@ -1714,6 +1715,6 @@ .git-resize-handle:hover, .git-resize-handle.active { - background: rgba(150, 150, 150, 0.12); + background: var(--app-active); } diff --git a/cptr/frontend/src/lib/components/KeyPill.svelte b/cptr/frontend/src/lib/components/KeyPill.svelte index 607d4c1..20d9692 100644 --- a/cptr/frontend/src/lib/components/KeyPill.svelte +++ b/cptr/frontend/src/lib/components/KeyPill.svelte @@ -9,6 +9,13 @@ {text} + + diff --git a/cptr/frontend/src/lib/components/Settings/Appearance.svelte b/cptr/frontend/src/lib/components/Settings/Appearance.svelte index 0379e9d..34af60c 100644 --- a/cptr/frontend/src/lib/components/Settings/Appearance.svelte +++ b/cptr/frontend/src/lib/components/Settings/Appearance.svelte @@ -348,7 +348,7 @@ .appearance-swatch { width: 1.5rem; height: 1.5rem; - border: 1px solid rgb(156 163 175 / 0.35); + border: 1px solid var(--app-border); border-radius: 624.9375rem; background: transparent; padding: 0.125rem; @@ -364,7 +364,7 @@ .ui-scale-range::-webkit-slider-runnable-track { height: 0.125rem; border-radius: 624.9375rem; - background: rgb(209 213 219 / 0.7); + background: var(--app-divider); } .ui-scale-range::-webkit-slider-thumb { @@ -373,32 +373,22 @@ height: 0.75rem; margin-top: -0.3125rem; border-radius: 624.9375rem; - border: 1px solid rgb(156 163 175 / 0.45); - background: rgb(255 255 255); + border: 1px solid var(--app-border); + background: var(--app-bg); } .ui-scale-range::-moz-range-track { height: 0.125rem; border-radius: 624.9375rem; - background: rgb(209 213 219 / 0.7); + background: var(--app-divider); } .ui-scale-range::-moz-range-thumb { width: 0.75rem; height: 0.75rem; border-radius: 624.9375rem; - border: 1px solid rgb(156 163 175 / 0.45); - background: rgb(255 255 255); + border: 1px solid var(--app-border); + background: var(--app-bg); } - :global(.dark) .ui-scale-range::-webkit-slider-runnable-track, - :global(.dark) .ui-scale-range::-moz-range-track { - background: rgb(255 255 255 / 0.12); - } - - :global(.dark) .ui-scale-range::-webkit-slider-thumb, - :global(.dark) .ui-scale-range::-moz-range-thumb { - border-color: rgb(255 255 255 / 0.18); - background: rgb(229 231 235); - } diff --git a/cptr/frontend/src/lib/components/Settings/Keyboard.svelte b/cptr/frontend/src/lib/components/Settings/Keyboard.svelte index 100ffba..b6517f8 100644 --- a/cptr/frontend/src/lib/components/Settings/Keyboard.svelte +++ b/cptr/frontend/src/lib/components/Settings/Keyboard.svelte @@ -168,7 +168,7 @@ .shortcut-row { @apply flex items-center gap-2 px-1 py-1.5; - border-bottom: 1px solid rgba(128, 128, 128, 0.04); + border-bottom: 1px solid var(--app-divider); } .shortcut-row:last-child { @@ -183,8 +183,8 @@ border-radius: 0.25rem; font-size: 0.625rem; font-weight: 500; - background: rgba(59, 130, 246, 0.1); - color: rgba(59, 130, 246, 0.7); + background: var(--app-active); + color: var(--app-fg); animation: pulse-subtle 1.5s ease-in-out infinite; } diff --git a/cptr/frontend/src/lib/components/Settings/Notifications.svelte b/cptr/frontend/src/lib/components/Settings/Notifications.svelte index 5f9d803..2673f4e 100644 --- a/cptr/frontend/src/lib/components/Settings/Notifications.svelte +++ b/cptr/frontend/src/lib/components/Settings/Notifications.svelte @@ -463,7 +463,7 @@ .notification-target-row { @apply flex items-center gap-3 px-1 py-1.5; - border-bottom: 1px solid rgba(128, 128, 128, 0.04); + border-bottom: 1px solid var(--app-divider); } .notification-target-row:last-child { diff --git a/cptr/frontend/src/lib/components/ShortcutBar.svelte b/cptr/frontend/src/lib/components/ShortcutBar.svelte index 1da2eff..3ef8882 100644 --- a/cptr/frontend/src/lib/components/ShortcutBar.svelte +++ b/cptr/frontend/src/lib/components/ShortcutBar.svelte @@ -224,18 +224,13 @@ align-items: center; gap: 0.25rem; padding: 0.25rem 0.5rem; - background: var(--color-gray-100); - border-top: 1px solid var(--color-gray-200); + background: var(--app-bg); + border-top: 1px solid var(--app-border); overflow-x: auto; flex-shrink: 0; scrollbar-width: none; } - :global(.dark) .shortcut-bar { - background: #0a0a0a; - border-top-color: rgba(255, 255, 255, 0.06); - } - .shortcut-bar::-webkit-scrollbar { display: none; } @@ -246,8 +241,8 @@ font-family: var(--font-mono); font-weight: 500; border-radius: 0.3125rem; - color: var(--color-gray-600); - background: var(--color-gray-200); + color: var(--app-fg-muted); + background: var(--app-hover); white-space: nowrap; flex-shrink: 0; transition: all 0.1s ease; @@ -256,22 +251,12 @@ -webkit-tap-highlight-color: transparent; } - :global(.dark) .key { - background: rgba(255, 255, 255, 0.08); - color: var(--color-gray-400); - } - .key:active { transform: scale(0.95); } .key.active { - background: var(--color-gray-900); - color: white; - } - - :global(.dark) .key.active { - background: white; - color: black; + background: var(--app-fg); + color: var(--app-bg); } diff --git a/cptr/frontend/src/lib/components/Sidebar.svelte b/cptr/frontend/src/lib/components/Sidebar.svelte index 31b1a58..b50bd73 100644 --- a/cptr/frontend/src/lib/components/Sidebar.svelte +++ b/cptr/frontend/src/lib/components/Sidebar.svelte @@ -151,7 +151,7 @@ .resize-handle:hover, .resize-handle.active { - background: rgba(150, 150, 150, 0.12); + background: var(--app-active); } } diff --git a/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte b/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte index 99022a7..c6f539a 100644 --- a/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte +++ b/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte @@ -411,7 +411,7 @@ transition: opacity 0.1s, transform 0.1s; - color: #9ca3af; + color: var(--app-fg-subtle); } :global(.dark) .ws-icon-chevron { @@ -458,12 +458,12 @@ width: 0.25rem; height: 0.25rem; border-radius: 50%; - background: #9ca3af; + background: var(--app-fg-subtle); animation: dotPulse 1s ease-in-out infinite; } :global(.dark) .ws-chat-loading-dot { - background: #6b7280; + background: var(--app-fg-muted); } .ws-chat-loading-dot:nth-child(2) { diff --git a/cptr/frontend/src/lib/components/Terminal.svelte b/cptr/frontend/src/lib/components/Terminal.svelte index 2088355..95f78ba 100644 --- a/cptr/frontend/src/lib/components/Terminal.svelte +++ b/cptr/frontend/src/lib/components/Terminal.svelte @@ -155,25 +155,27 @@ resizeTimeout = setTimeout(() => doFit(), 350); } - function isDarkMode(): boolean { - return document.documentElement.classList.contains('dark'); + function themeColor(name: '--app-bg' | '--app-fg') { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } - const darkTheme = { - background: '#000000', - foreground: '#e0e0e0', - cursor: '#e0e0e0', - cursorAccent: '#000000', - selectionBackground: 'rgba(255, 255, 255, 0.2)' - }; - - const lightTheme = { - background: '#ffffff', - foreground: '#1a1a1a', - cursor: '#1a1a1a', - cursorAccent: '#ffffff', - selectionBackground: 'rgba(0, 0, 0, 0.15)' - }; + function withAlpha(color: string, alpha: number) { + const hex = /^#([0-9a-f]{6})$/i.exec(color)?.[1]; + if (!hex) return color; + return `rgba(${parseInt(hex.slice(0, 2), 16)}, ${parseInt(hex.slice(2, 4), 16)}, ${parseInt(hex.slice(4, 6), 16)}, ${alpha})`; + } + + function terminalTheme() { + const background = themeColor('--app-bg'); + const foreground = themeColor('--app-fg'); + return { + background, + foreground, + cursor: foreground, + cursorAccent: background, + selectionBackground: withAlpha(foreground, 0.2) + }; + } // Send input to PTY via WebSocket (binary prefix protocol). // Writes directly into the pre-allocated buffer. Zero allocation @@ -256,16 +258,19 @@ scrollback: 10000, macOptionClickForceSelection: true, disableStdin: readOnly, - theme: isDarkMode() ? darkTheme : lightTheme + theme: terminalTheme() }); // Watch for theme changes const observer = new MutationObserver(() => { if (term) { - term.options.theme = isDarkMode() ? darkTheme : lightTheme; + term.options.theme = terminalTheme(); } }); - observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class', 'style'] + }); // Handle macOS key combos that need special escape sequences term.attachCustomKeyEventHandler((e: KeyboardEvent) => { @@ -521,7 +526,7 @@ :global(.xterm-viewport) { overflow-y: auto !important; scrollbar-width: thin; - scrollbar-color: rgba(75, 85, 99, 0.4) transparent; + scrollbar-color: color-mix(in oklab, var(--app-fg) 40%, transparent) transparent; overscroll-behavior: contain; } /* When moved to flex sibling on mobile, override xterm's offscreen diff --git a/cptr/frontend/src/lib/components/automations/ScheduleDropdown.svelte b/cptr/frontend/src/lib/components/automations/ScheduleDropdown.svelte index c2c530f..331e2ea 100644 --- a/cptr/frontend/src/lib/components/automations/ScheduleDropdown.svelte +++ b/cptr/frontend/src/lib/components/automations/ScheduleDropdown.svelte @@ -282,20 +282,12 @@ padding: 0.25rem 0.625rem; border-radius: 1rem; font-size: 0.75rem; - color: var(--color-gray-600); + color: var(--app-fg-muted); transition: background 0.1s; } - :global(.dark) .schedule-trigger { - color: var(--color-gray-400); - } - .schedule-trigger:hover { - background: rgba(0, 0, 0, 0.05); - } - - :global(.dark) .schedule-trigger:hover { - background: rgba(255, 255, 255, 0.05); + background: var(--app-hover); } .schedule-panel { @@ -304,15 +296,10 @@ width: 12rem; padding: 0.25rem; border-radius: 1rem; - background: white; - border: 1px solid var(--color-gray-200); + background: var(--app-bg); + border: 1px solid var(--app-border); box-shadow: - 0 0.25rem 0.375rem -0.0625rem rgba(0, 0, 0, 0.1), - 0 0.125rem 0.25rem -0.125rem rgba(0, 0, 0, 0.1); - } - - :global(.dark) .schedule-panel { - background: #0a0a0a; - border-color: rgba(255, 255, 255, 0.08); + 0 0.25rem 0.375rem -0.0625rem color-mix(in oklab, var(--app-fg) 10%, transparent), + 0 0.125rem 0.25rem -0.125rem color-mix(in oklab, var(--app-fg) 10%, transparent); } diff --git a/cptr/frontend/src/lib/components/chat/AskUserCard.svelte b/cptr/frontend/src/lib/components/chat/AskUserCard.svelte new file mode 100644 index 0000000..30ec79a --- /dev/null +++ b/cptr/frontend/src/lib/components/chat/AskUserCard.svelte @@ -0,0 +1,152 @@ + + +
+
+
Planning question
+ {#if pending} +
+ Continuing with recommendations in {timeLabel(secondsRemaining)} +
+ {:else if item.timed_out || resolved.timed_out} +
Used recommendations
+ {/if} +
+ +
+ {#each questions as question} +
+
+
{question.header}
+
{question.question}
+
+ {#if pending} +
+ {#each question.options || [] as option, optionIndex} + + {/each} + + {#if selections[question.id] === '__other__'} + + (otherAnswers = { ...otherAnswers, [question.id]: event.currentTarget.value })} + /> + {/if} +
+ {:else} +
+ {resolved.answers?.[question.id]?.answers?.join(', ') || 'No answer recorded'} +
+ {/if} +
+ {/each} + + {#if pending} + + {/if} +
+
diff --git a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte index bc0de9c..8a791b6 100644 --- a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte +++ b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte @@ -8,6 +8,7 @@ import MessageTimestamp from './MessageTimestamp.svelte'; import ReasoningCollapsible from './ReasoningCollapsible.svelte'; import ToolCallCollapsible from './ToolCallCollapsible.svelte'; + import AskUserCard from './AskUserCard.svelte'; import { currentWorkspace, openFileTab } from '$lib/stores'; import { ttsConfigured, ttsEnabled } from '$lib/stores/audio'; import { tooltip } from '$lib/tooltip'; @@ -27,6 +28,12 @@ siblingTotal?: number; speaking?: boolean; onapprove: (messageId: string, callId: string, approved: boolean) => void; + onanswer: ( + messageId: string, + callId: string, + answers: Record, + timedOut: boolean + ) => void; onnavigate?: (direction: -1 | 1) => void; onfork?: () => void; onregenerate?: () => void; @@ -45,6 +52,7 @@ siblingTotal = 1, speaking = false, onapprove, + onanswer, onnavigate, onfork, onregenerate, @@ -262,7 +270,19 @@ index: number; } - type DisplayItem = ActivityGroup | MessageItem | ArtifactItem | ImageItem | FileItem; + interface AskUserItem { + type: 'ask_user_item'; + item: any; + output: any; + } + + type DisplayItem = + | ActivityGroup + | MessageItem + | ArtifactItem + | ImageItem + | FileItem + | AskUserItem; const outputText = $derived.by((): string => { return (output || []) @@ -343,9 +363,14 @@ for (const [index, item] of output.entries()) { if (item.type === 'function_call') { - ensureGroup(); - currentGroup!.entries.push(item); - currentGroup!.calls.push(item); + if (item.name === 'ask_user') { + flushGroup(); + items.push({ type: 'ask_user_item', item, output: outputMap.get(item.call_id) }); + } else { + ensureGroup(); + currentGroup!.entries.push(item); + currentGroup!.calls.push(item); + } } else if (item.type === 'reasoning') { ensureGroup(); currentGroup!.entries.push(item); @@ -547,6 +572,14 @@ {/if} + {:else if displayItem.type === 'ask_user_item'} + {:else if displayItem.type === 'activity_group'} {#if displayItem.entries.length === 1} {@const item = displayItem.entries[0]} diff --git a/cptr/frontend/src/lib/components/chat/ChatFilePreview.svelte b/cptr/frontend/src/lib/components/chat/ChatFilePreview.svelte index 23db9d4..4406cec 100644 --- a/cptr/frontend/src/lib/components/chat/ChatFilePreview.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatFilePreview.svelte @@ -130,11 +130,7 @@ min-height: 10rem; overflow: hidden; resize: vertical; - background: white; - } - - :global(.dark) .preview-box { - background: black; + background: var(--app-bg); } .preview-box.tall { @@ -152,11 +148,7 @@ overflow: auto; resize: vertical; padding: 0.875rem 1rem; - background: white; - } - - :global(.dark) .text-preview { - background: rgba(0, 0, 0, 0.25); + background: var(--app-bg); } .mono-preview { @@ -166,16 +158,12 @@ font-family: var(--font-mono); font-size: 0.75rem; line-height: 1.35; - color: var(--color-gray-800); - } - - :global(.dark) .mono-preview { - color: var(--color-gray-200); + color: var(--app-fg); } .media-preview { padding: 0.75rem 1rem; - background: white; + background: var(--app-bg); } .video-preview { @@ -186,10 +174,6 @@ resize: vertical; } - :global(.dark) .media-preview { - background: rgba(0, 0, 0, 0.25); - } - .media-preview audio, .media-preview video { width: 100%; @@ -201,10 +185,6 @@ .preview-state { padding: 0.75rem 1rem; font-size: 0.75rem; - color: var(--color-gray-500); - } - - :global(.dark) .preview-state { - color: var(--color-gray-400); + color: var(--app-fg-muted); } diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte index c2958b4..b9a8365 100644 --- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte @@ -7,6 +7,7 @@ forkChat as apiForkChat, sendMessage as apiSendMessage, approveToolCall, + answerAskUser, cancelTask, compactChat as apiCompactChat, updateCurrentMessage, @@ -1115,6 +1116,19 @@ }); } + function handleAskUserAnswer( + messageId: string, + callId: string, + answers: Record, + timedOut: boolean + ) { + if (!chatId) return; + answerAskUser(chatId, messageId, callId, answers, timedOut).catch((err) => { + console.error('[chat] ask_user answer error', err); + loadChat(chatId!); + }); + } + function handleNavigate(messageId: string, direction: -1 | 1) { const entry = activePath.find((p) => p.msg.id === messageId); if (!entry) return; @@ -1654,6 +1668,7 @@ onedit={(c, o, submit) => handleEditMessage(msg.id, c, o, submit)} onspeak={() => speakMessage(msg.id)} onapprove={handleApprove} + onanswer={handleAskUserAnswer} /> {/if} {/each} diff --git a/cptr/frontend/src/lib/components/common/Pagination.svelte b/cptr/frontend/src/lib/components/common/Pagination.svelte index 36b8bc2..65e2c66 100644 --- a/cptr/frontend/src/lib/components/common/Pagination.svelte +++ b/cptr/frontend/src/lib/components/common/Pagination.svelte @@ -102,7 +102,7 @@ border: none; border-radius: 0.3125rem; background: transparent; - color: #c4c4c4; + color: var(--app-fg-subtle); font-size: 0.65625rem; font-weight: 500; cursor: pointer; @@ -113,13 +113,13 @@ } .pagination-btn:hover:not(:disabled):not(.active) { - background: rgba(0, 0, 0, 0.03); - color: #9ca3af; + background: var(--app-hover); + color: var(--app-fg-muted); } .pagination-btn.active { - background: rgba(0, 0, 0, 0.04); - color: #8b8b8b; + background: var(--app-active); + color: var(--app-fg); } .pagination-btn:disabled { @@ -134,27 +134,9 @@ min-width: 1.375rem; height: 1.375rem; font-size: 0.65625rem; - color: #d1d5db; + color: var(--app-fg-subtle); user-select: none; letter-spacing: 0.0625rem; } - /* Dark mode */ - :global(.dark) .pagination-btn { - color: #4b5563; - } - - :global(.dark) .pagination-btn:hover:not(:disabled):not(.active) { - background: rgba(255, 255, 255, 0.04); - color: #6b7280; - } - - :global(.dark) .pagination-btn.active { - background: rgba(255, 255, 255, 0.06); - color: #9ca3af; - } - - :global(.dark) .pagination-ellipsis { - color: #4b5563; - } diff --git a/cptr/frontend/src/lib/components/markdown/EditorToolbar.svelte b/cptr/frontend/src/lib/components/markdown/EditorToolbar.svelte index 51f97b8..beb758e 100644 --- a/cptr/frontend/src/lib/components/markdown/EditorToolbar.svelte +++ b/cptr/frontend/src/lib/components/markdown/EditorToolbar.svelte @@ -222,17 +222,12 @@ align-items: center; height: 2.25rem; padding: 0 0.5rem; - border-bottom: 1px solid var(--color-gray-200); - background: white; + border-bottom: 1px solid var(--app-border); + background: var(--app-bg); flex-shrink: 0; overflow: hidden; } - :global(.dark) .editor-toolbar { - background: #000; - border-bottom-color: rgba(255, 255, 255, 0.06); - } - .toolbar-scroll { display: flex; align-items: center; @@ -258,7 +253,7 @@ border: none; border-radius: 0.3125rem; background: transparent; - color: var(--color-gray-500); + color: var(--app-fg-muted); font-size: 0.75rem; font-weight: 500; cursor: pointer; @@ -268,13 +263,13 @@ } .tb-btn:hover { - background: var(--color-gray-100); - color: var(--color-gray-700); + background: var(--app-hover); + color: var(--app-fg); } .tb-btn.active { - background: var(--color-gray-200); - color: var(--color-gray-900); + background: var(--app-active); + color: var(--app-fg); } .tb-btn:disabled { @@ -282,20 +277,6 @@ cursor: default; } - :global(.dark) .tb-btn { - color: var(--color-gray-500); - } - - :global(.dark) .tb-btn:hover { - background: rgba(255, 255, 255, 0.06); - color: var(--color-gray-300); - } - - :global(.dark) .tb-btn.active { - background: rgba(255, 255, 255, 0.1); - color: white; - } - .heading-btn { font-size: 0.6875rem; font-weight: 700; @@ -305,15 +286,11 @@ .tb-divider { width: 1px; height: 1rem; - background: var(--color-gray-200); + background: var(--app-divider); margin: 0 0.25rem; flex-shrink: 0; } - :global(.dark) .tb-divider { - background: rgba(255, 255, 255, 0.08); - } - .highlight-icon { background: rgba(255, 213, 79, 0.4); border-radius: 0.125rem; @@ -337,8 +314,8 @@ padding-bottom: env(safe-area-inset-bottom, 0); } - :global(.dark) .editor-toolbar { - border-top-color: rgba(255, 255, 255, 0.06); + .editor-toolbar { + border-top-color: var(--app-border); border-bottom: none; } diff --git a/cptr/frontend/src/lib/components/markdown/MermaidBlock.svelte b/cptr/frontend/src/lib/components/markdown/MermaidBlock.svelte index 0e870db..dea8dc0 100644 --- a/cptr/frontend/src/lib/components/markdown/MermaidBlock.svelte +++ b/cptr/frontend/src/lib/components/markdown/MermaidBlock.svelte @@ -54,25 +54,17 @@ margin: 0 0 0.75rem; border-radius: 0.5rem; overflow: hidden; - border: 1px solid var(--color-gray-200); - } - - :global(.dark) .mermaid-block { - border-color: rgba(255, 255, 255, 0.06); + border: 1px solid var(--app-border); } .mermaid-container { display: flex; justify-content: center; padding: 1rem; - background: var(--color-gray-50); + background: var(--app-hover); overflow-x: auto; } - :global(.dark) .mermaid-container { - background: rgba(255, 255, 255, 0.02); - } - .mermaid-container :global(svg) { max-width: 100%; height: auto; @@ -101,14 +93,10 @@ margin: 0; padding: 0.5rem 0.75rem; font-size: 0.75rem; - background: var(--color-gray-100); + background: var(--app-hover); border-radius: 0.25rem; - color: var(--color-gray-600); + color: var(--app-fg-muted); font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; } - :global(.dark) .mermaid-source { - background: rgba(255, 255, 255, 0.04); - color: var(--color-gray-400); - } diff --git a/cptr/frontend/src/lib/components/markdown/RichTextEditor.svelte b/cptr/frontend/src/lib/components/markdown/RichTextEditor.svelte index 8c09654..9fbf711 100644 --- a/cptr/frontend/src/lib/components/markdown/RichTextEditor.svelte +++ b/cptr/frontend/src/lib/components/markdown/RichTextEditor.svelte @@ -220,15 +220,14 @@ min-height: 100%; font-size: 0.875rem; line-height: 1.7; - color: var(--color-gray-800); + color: var(--app-fg); outline: none; word-wrap: break-word; overflow-wrap: break-word; } .rte-container.dark-mode :global(.rte-prosemirror) { - color: var(--color-gray-200); - caret-color: white; + caret-color: var(--app-fg); } /* ── Placeholder ──────────────────────────────── */ @@ -236,15 +235,11 @@ .rte-container :global(.rte-prosemirror p.is-editor-empty:first-child::before) { content: attr(data-placeholder); float: left; - color: var(--color-gray-400); + color: var(--app-fg-subtle); pointer-events: none; height: 0; } - .rte-container.dark-mode :global(.rte-prosemirror p.is-editor-empty:first-child::before) { - color: var(--color-gray-600); - } - /* ── Headings (match MarkdownRenderer sizes) ──── */ .rte-container :global(.rte-prosemirror h1) { @@ -328,45 +323,32 @@ .rte-container :global(.rte-prosemirror blockquote) { margin: 0 0 0.75rem; padding: 0.25rem 1rem; - border-left: 0.1875rem solid var(--color-gray-300); - color: var(--color-gray-600); - } - - .rte-container.dark-mode :global(.rte-prosemirror blockquote) { - border-left-color: rgba(255, 255, 255, 0.15); - color: var(--color-gray-400); + border-left: 0.1875rem solid var(--app-border); + color: var(--app-fg-muted); } /* ── Horizontal rule ──────────────────────────── */ .rte-container :global(.rte-prosemirror hr) { border: none; - border-top: 1px solid var(--color-gray-200); + border-top: 1px solid var(--app-border); margin: 1.25rem 0; } - .rte-container.dark-mode :global(.rte-prosemirror hr) { - border-top-color: rgba(255, 255, 255, 0.08); - } - /* ── Code (inline) ─────────────────────────────── */ .rte-container :global(.rte-prosemirror code) { - background: var(--color-gray-100); + background: var(--app-hover); border-radius: 0.1875rem; padding: 0.0625rem 0.3125rem; font-size: 0.78125rem; font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; } - .rte-container.dark-mode :global(.rte-prosemirror code) { - background: rgba(255, 255, 255, 0.08); - } - /* ── Code blocks ───────────────────────────────── */ .rte-container :global(.rte-prosemirror pre) { - background: var(--color-gray-100); + background: var(--app-hover); border-radius: 0.375rem; padding: 0.75rem 1rem; overflow-x: auto; @@ -375,10 +357,6 @@ font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; } - .rte-container.dark-mode :global(.rte-prosemirror pre) { - background: rgba(255, 255, 255, 0.04); - } - .rte-container :global(.rte-prosemirror pre code) { background: none; padding: 0; @@ -397,29 +375,20 @@ .rte-container :global(.rte-prosemirror th), .rte-container :global(.rte-prosemirror td) { - border: 1px solid var(--color-gray-200); + border: 1px solid var(--app-border); padding: 0.375rem 0.625rem; text-align: left; } .rte-container :global(.rte-prosemirror th) { font-weight: 600; - background: var(--color-gray-50); - } - - .rte-container.dark-mode :global(.rte-prosemirror th) { - background: rgba(255, 255, 255, 0.04); - } - - .rte-container.dark-mode :global(.rte-prosemirror th), - .rte-container.dark-mode :global(.rte-prosemirror td) { - border-color: rgba(255, 255, 255, 0.08); + background: var(--app-hover); } /* ── Links ──────────────────────────────────────── */ .rte-container :global(.rte-prosemirror a) { - color: #3b82f6; + color: var(--app-fg); text-decoration: none; cursor: pointer; } diff --git a/cptr/frontend/src/lib/components/preview/CsvTable.svelte b/cptr/frontend/src/lib/components/preview/CsvTable.svelte index d38c99f..a70b381 100644 --- a/cptr/frontend/src/lib/components/preview/CsvTable.svelte +++ b/cptr/frontend/src/lib/components/preview/CsvTable.svelte @@ -88,64 +88,40 @@ } th { - background: var(--color-gray-50); - color: var(--color-gray-600); + background: var(--app-hover); + color: var(--app-fg-muted); font-weight: 600; font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.03em; padding: 0.375rem 0.75rem; text-align: left; - border-bottom: 1px solid var(--color-gray-200); - } - - :global(.dark) th { - background: rgba(255, 255, 255, 0.04); - color: var(--color-gray-400); - border-bottom-color: rgba(255, 255, 255, 0.06); + border-bottom: 1px solid var(--app-border); } td { padding: 0.25rem 0.75rem; - color: var(--color-gray-800); - border-bottom: 1px solid var(--color-gray-100); - } - - :global(.dark) td { - color: var(--color-gray-200); - border-bottom-color: rgba(255, 255, 255, 0.04); + color: var(--app-fg); + border-bottom: 1px solid var(--app-divider); } tbody tr:hover td { - background: rgba(0, 0, 0, 0.02); - } - - :global(.dark) tbody tr:hover td { - background: rgba(255, 255, 255, 0.02); + background: var(--app-hover); } /* Row numbers */ .row-num { - color: var(--color-gray-400); + color: var(--app-fg-subtle); font-size: 0.6875rem; text-align: right; padding-right: 0.5rem; padding-left: 0.5rem; user-select: none; min-width: 2rem; - border-right: 1px solid var(--color-gray-100); - } - - :global(.dark) .row-num { - border-right-color: rgba(255, 255, 255, 0.04); - color: var(--color-gray-600); + border-right: 1px solid var(--app-divider); } thead .row-num { - border-right-color: var(--color-gray-200); - } - - :global(.dark) thead .row-num { - border-right-color: rgba(255, 255, 255, 0.06); + border-right-color: var(--app-border); } diff --git a/cptr/frontend/src/lib/components/preview/ImagePreview.svelte b/cptr/frontend/src/lib/components/preview/ImagePreview.svelte index 3d6c1d7..a0d6c65 100644 --- a/cptr/frontend/src/lib/components/preview/ImagePreview.svelte +++ b/cptr/frontend/src/lib/components/preview/ImagePreview.svelte @@ -118,12 +118,7 @@ width: 100%; height: 100%; overflow: hidden; - background: repeating-conic-gradient(var(--color-gray-100) 0% 25%, transparent 0% 50%) 50% / - 1rem 1rem; - } - - :global(.dark) .image-preview { - background: repeating-conic-gradient(rgba(255, 255, 255, 0.04) 0% 25%, transparent 0% 50%) 50% / + background: repeating-conic-gradient(var(--app-hover) 0% 25%, transparent 0% 50%) 50% / 1rem 1rem; } @@ -153,16 +148,10 @@ gap: 0.125rem; padding: 0.125rem; border-radius: 0.5rem; - background: rgba(255, 255, 255, 0.9); + background: color-mix(in oklab, var(--app-bg) 90%, transparent); backdrop-filter: blur(0.5rem); - border: 1px solid rgba(0, 0, 0, 0.08); - box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.1); - } - - :global(.dark) .zoom-toolbar { - background: rgba(30, 30, 30, 0.9); - border-color: rgba(255, 255, 255, 0.1); - box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.3); + border: 1px solid var(--app-border); + box-shadow: 0 0.125rem 0.5rem color-mix(in oklab, var(--app-fg) 10%, transparent); } .zoom-btn { @@ -172,18 +161,13 @@ width: 1.75rem; height: 1.75rem; border-radius: 0.375rem; - color: var(--color-gray-500); + color: var(--app-fg-muted); transition: all 0.1s; } .zoom-btn:hover { - background: rgba(0, 0, 0, 0.06); - color: var(--color-gray-700); - } - - :global(.dark) .zoom-btn:hover { - background: rgba(255, 255, 255, 0.1); - color: var(--color-gray-300); + background: var(--app-hover); + color: var(--app-fg); } .zoom-level { @@ -192,19 +176,14 @@ font-size: 0.6875rem; font-weight: 500; text-align: center; - color: var(--color-gray-500); + color: var(--app-fg-muted); border-radius: 0.375rem; font-variant-numeric: tabular-nums; transition: all 0.1s; } .zoom-level:hover { - background: rgba(0, 0, 0, 0.06); - color: var(--color-gray-700); - } - - :global(.dark) .zoom-level:hover { - background: rgba(255, 255, 255, 0.1); - color: var(--color-gray-300); + background: var(--app-hover); + color: var(--app-fg); } diff --git a/cptr/frontend/src/lib/components/preview/OfficePreview.svelte b/cptr/frontend/src/lib/components/preview/OfficePreview.svelte index d1c152f..dbdcf8b 100644 --- a/cptr/frontend/src/lib/components/preview/OfficePreview.svelte +++ b/cptr/frontend/src/lib/components/preview/OfficePreview.svelte @@ -360,11 +360,7 @@ padding: 1.5rem 2rem; font-size: 0.875rem; line-height: 1.7; - color: var(--color-gray-800); - } - - :global(.dark) .docx-content { - color: var(--color-gray-200); + color: var(--app-fg); } .docx-content :global(h1) { @@ -392,7 +388,7 @@ } .docx-content :global(td), .docx-content :global(th) { - border: 1px solid var(--color-gray-200); + border: 1px solid var(--app-border); padding: 0.25rem 0.5rem; } .docx-content :global(img) { @@ -405,34 +401,25 @@ .sheet-tabs { display: flex; gap: 0; - border-bottom: 1px solid var(--color-gray-200); + border-bottom: 1px solid var(--app-border); padding: 0 0.25rem; flex-shrink: 0; overflow-x: auto; } - :global(.dark) .sheet-tabs { - border-bottom-color: rgba(255, 255, 255, 0.06); - } - .sheet-tab { padding: 0.375rem 0.75rem; font-size: 0.6875rem; font-weight: 500; - color: var(--color-gray-500); + color: var(--app-fg-muted); border-bottom: 0.125rem solid transparent; white-space: nowrap; transition: all 0.1s; } .sheet-tab.active { - color: var(--color-gray-900); - border-bottom-color: var(--color-gray-900); - } - - :global(.dark) .sheet-tab.active { - color: white; - border-bottom-color: white; + color: var(--app-fg); + border-bottom-color: var(--app-fg); } .xlsx-content { @@ -455,48 +442,30 @@ } .xlsx-content :global(th) { - background: var(--color-gray-50); - color: var(--color-gray-500); + background: var(--app-hover); + color: var(--app-fg-muted); font-weight: 600; font-size: 0.625rem; padding: 0.25rem 0.5rem; text-align: center; - border-bottom: 1px solid var(--color-gray-200); - } - - :global(.dark) .xlsx-content :global(th) { - background: rgba(255, 255, 255, 0.04); - color: var(--color-gray-400); - border-bottom-color: rgba(255, 255, 255, 0.06); + border-bottom: 1px solid var(--app-border); } .xlsx-content :global(td) { padding: 0.1875rem 0.5rem; - color: var(--color-gray-800); - border-bottom: 1px solid var(--color-gray-100); - border-right: 1px solid var(--color-gray-100); - } - - :global(.dark) .xlsx-content :global(td) { - color: var(--color-gray-200); - border-bottom-color: rgba(255, 255, 255, 0.04); - border-right-color: rgba(255, 255, 255, 0.04); + color: var(--app-fg); + border-bottom: 1px solid var(--app-divider); + border-right: 1px solid var(--app-divider); } .xlsx-content :global(.rn) { - color: var(--color-gray-400); + color: var(--app-fg-subtle); font-size: 0.625rem; text-align: right; user-select: none; min-width: 1.75rem; - border-right: 1px solid var(--color-gray-200); - background: var(--color-gray-50); - } - - :global(.dark) .xlsx-content :global(.rn) { - border-right-color: rgba(255, 255, 255, 0.06); - background: rgba(255, 255, 255, 0.02); - color: var(--color-gray-600); + border-right: 1px solid var(--app-border); + background: var(--app-hover); } .xlsx-content :global(.num) { @@ -517,18 +486,14 @@ flex-direction: column; gap: 0.5rem; align-items: center; - background: var(--color-gray-100); - } - - :global(.dark) .pptx-content { - background: rgba(255, 255, 255, 0.02); + background: var(--app-hover); } .slide { position: relative; width: 100%; max-width: 50rem; - box-shadow: 0 0.0625rem 0.25rem rgba(0, 0, 0, 0.12); + box-shadow: 0 0.0625rem 0.25rem color-mix(in oklab, var(--app-fg) 12%, transparent); border-radius: 0.25rem; overflow: hidden; } diff --git a/cptr/frontend/src/lib/components/preview/PDFViewer.svelte b/cptr/frontend/src/lib/components/preview/PDFViewer.svelte index 3b3edc0..3087788 100644 --- a/cptr/frontend/src/lib/components/preview/PDFViewer.svelte +++ b/cptr/frontend/src/lib/components/preview/PDFViewer.svelte @@ -319,19 +319,13 @@ gap: 0.125rem; padding: 0.125rem; border-radius: 0.5rem; - background: rgba(255, 255, 255, 0.9); + background: color-mix(in oklab, var(--app-bg) 90%, transparent); backdrop-filter: blur(0.5rem); - border: 1px solid rgba(0, 0, 0, 0.08); - box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.1); + border: 1px solid var(--app-border); + box-shadow: 0 0.125rem 0.5rem color-mix(in oklab, var(--app-fg) 10%, transparent); z-index: 10; } - :global(.dark) .zoom-toolbar { - background: rgba(30, 30, 30, 0.9); - border-color: rgba(255, 255, 255, 0.1); - box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.3); - } - .zoom-btn { display: flex; align-items: center; @@ -339,18 +333,13 @@ width: 1.75rem; height: 1.75rem; border-radius: 0.375rem; - color: var(--color-gray-500); + color: var(--app-fg-muted); transition: all 0.1s; } .zoom-btn:hover { - background: rgba(0, 0, 0, 0.06); - color: var(--color-gray-700); - } - - :global(.dark) .zoom-btn:hover { - background: rgba(255, 255, 255, 0.1); - color: var(--color-gray-300); + background: var(--app-hover); + color: var(--app-fg); } .zoom-level { @@ -359,20 +348,15 @@ font-size: 0.6875rem; font-weight: 500; text-align: center; - color: var(--color-gray-500); + color: var(--app-fg-muted); border-radius: 0.375rem; font-variant-numeric: tabular-nums; transition: all 0.1s; } .zoom-level:hover { - background: rgba(0, 0, 0, 0.06); - color: var(--color-gray-700); - } - - :global(.dark) .zoom-level:hover { - background: rgba(255, 255, 255, 0.1); - color: var(--color-gray-300); + background: var(--app-hover); + color: var(--app-fg); } /* ── pdfjs TextLayer styles ─────────────────────────────────── */ @@ -421,7 +405,7 @@ } :global(.textLayer ::selection) { - background: rgba(0, 100, 255, 0.25); + background: color-mix(in oklab, var(--app-fg) 25%, transparent); } :global(.textLayer br::selection) { diff --git a/cptr/frontend/src/lib/components/preview/SqliteView.svelte b/cptr/frontend/src/lib/components/preview/SqliteView.svelte index a6810ff..95839ef 100644 --- a/cptr/frontend/src/lib/components/preview/SqliteView.svelte +++ b/cptr/frontend/src/lib/components/preview/SqliteView.svelte @@ -266,16 +266,12 @@ display: flex; align-items: center; gap: 0; - border-bottom: 1px solid var(--color-gray-200); + border-bottom: 1px solid var(--app-border); padding: 0 0.25rem; flex-shrink: 0; overflow-x: auto; } - :global(.dark) .tab-bar { - border-bottom-color: rgba(255, 255, 255, 0.06); - } - .tabs { display: flex; gap: 0; @@ -288,28 +284,19 @@ padding: 0.375rem 0.75rem; font-size: 0.6875rem; font-weight: 500; - color: var(--color-gray-500); + color: var(--app-fg-muted); border-bottom: 0.125rem solid transparent; transition: all 0.1s; white-space: nowrap; } .tab:hover { - color: var(--color-gray-700); - } - - :global(.dark) .tab:hover { - color: var(--color-gray-300); + color: var(--app-fg); } .tab.active { - color: var(--color-gray-900); - border-bottom-color: var(--color-gray-900); - } - - :global(.dark) .tab.active { - color: white; - border-bottom-color: white; + color: var(--app-fg); + border-bottom-color: var(--app-fg); } .query-tab { @@ -324,49 +311,40 @@ display: flex; gap: 0.5rem; padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--color-gray-100); + border-bottom: 1px solid var(--app-divider); flex-shrink: 0; } - :global(.dark) .query-bar { - border-bottom-color: rgba(255, 255, 255, 0.04); - } - .query-input { flex: 1; font-family: var(--font-mono); font-size: 0.75rem; padding: 0.375rem 0.625rem; - border: 1px solid var(--color-gray-200); + border: 1px solid var(--app-border); border-radius: 0.375rem; background: transparent; - color: var(--color-gray-800); + color: var(--app-fg); resize: vertical; outline: none; } - :global(.dark) .query-input { - border-color: rgba(255, 255, 255, 0.1); - color: var(--color-gray-200); - } - .query-input:focus { - border-color: #3b82f6; + border-color: var(--app-fg); } .run-btn { padding: 0.375rem 0.75rem; font-size: 0.6875rem; font-weight: 500; - color: white; - background: #2563eb; + color: var(--app-bg); + background: var(--app-fg); border-radius: 0.375rem; align-self: flex-end; transition: background 0.1s; } .run-btn:hover { - background: #1d4ed8; + background: var(--app-fg-muted); } .query-error { @@ -400,62 +378,42 @@ } th { - background: var(--color-gray-50); - color: var(--color-gray-600); + background: var(--app-hover); + color: var(--app-fg-muted); font-weight: 600; font-size: 0.6875rem; padding: 0.3125rem 0.625rem; text-align: left; - border-bottom: 1px solid var(--color-gray-200); - } - - :global(.dark) th { - background: rgba(255, 255, 255, 0.04); - color: var(--color-gray-400); - border-bottom-color: rgba(255, 255, 255, 0.06); + border-bottom: 1px solid var(--app-border); } td { padding: 0.1875rem 0.625rem; - color: var(--color-gray-800); - border-bottom: 1px solid var(--color-gray-100); - } - - :global(.dark) td { - color: var(--color-gray-200); - border-bottom-color: rgba(255, 255, 255, 0.04); + color: var(--app-fg); + border-bottom: 1px solid var(--app-divider); } tbody tr:hover td { - background: rgba(0, 0, 0, 0.02); - } - - :global(.dark) tbody tr:hover td { - background: rgba(255, 255, 255, 0.02); + background: var(--app-hover); } .row-num { - color: var(--color-gray-400); + color: var(--app-fg-subtle); font-size: 0.625rem; text-align: right; padding-right: 0.375rem; user-select: none; min-width: 2rem; - border-right: 1px solid var(--color-gray-100); - } - - :global(.dark) .row-num { - border-right-color: rgba(255, 255, 255, 0.04); - color: var(--color-gray-600); + border-right: 1px solid var(--app-divider); } .cell-null { - color: var(--color-gray-400); + color: var(--app-fg-subtle); font-style: italic; } .cell-blob { - color: var(--color-gray-400); + color: var(--app-fg-subtle); font-style: italic; } @@ -475,30 +433,21 @@ justify-content: center; gap: 0.75rem; padding: 0.375rem; - border-top: 1px solid var(--color-gray-100); + border-top: 1px solid var(--app-divider); flex-shrink: 0; } - :global(.dark) .pagination { - border-top-color: rgba(255, 255, 255, 0.04); - } - .page-btn { font-size: 0.6875rem; padding: 0.1875rem 0.625rem; border-radius: 0.25rem; - color: var(--color-gray-500); + color: var(--app-fg-muted); transition: all 0.1s; } .page-btn:hover:not(:disabled) { - background: var(--color-gray-100); - color: var(--color-gray-700); - } - - :global(.dark) .page-btn:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.06); - color: var(--color-gray-300); + background: var(--app-hover); + color: var(--app-fg); } .page-btn:disabled { @@ -508,7 +457,7 @@ .page-info { font-size: 0.6875rem; - color: var(--color-gray-400); + color: var(--app-fg-subtle); font-variant-numeric: tabular-nums; } diff --git a/cptr/frontend/src/lib/components/preview/SvgPreview.svelte b/cptr/frontend/src/lib/components/preview/SvgPreview.svelte index c4ca8b2..b4b6af9 100644 --- a/cptr/frontend/src/lib/components/preview/SvgPreview.svelte +++ b/cptr/frontend/src/lib/components/preview/SvgPreview.svelte @@ -125,14 +125,10 @@ justify-content: space-between; height: 2.25rem; padding: 0 0.75rem; - border-bottom: 1px solid var(--color-gray-200); + border-bottom: 1px solid var(--app-border); flex-shrink: 0; } - :global(.dark) .toolbar { - border-bottom-color: rgba(255, 255, 255, 0.06); - } - .toolbar-left { display: flex; align-items: center; @@ -147,7 +143,7 @@ .file-label { font-size: 0.6875rem; font-weight: 500; - color: var(--color-gray-500); + color: var(--app-fg-muted); } .toolbar-btn { @@ -158,24 +154,16 @@ height: 1.5rem; padding: 0 0.25rem; border-radius: 0.375rem; - color: var(--color-gray-400); + color: var(--app-fg-subtle); transition: all 0.1s; } .toolbar-btn:hover { - color: var(--color-gray-600); - } - - :global(.dark) .toolbar-btn:hover { - color: var(--color-gray-300); + color: var(--app-fg-muted); } .toolbar-btn.active { - color: var(--color-gray-900); - } - - :global(.dark) .toolbar-btn.active { - color: white; + color: var(--app-fg); } .zoom-text { @@ -193,12 +181,7 @@ width: 100%; height: 100%; overflow: hidden; - background: repeating-conic-gradient(var(--color-gray-100) 0% 25%, transparent 0% 50%) 50% / - 1rem 1rem; - } - - :global(.dark) .visual-container { - background: repeating-conic-gradient(rgba(255, 255, 255, 0.04) 0% 25%, transparent 0% 50%) 50% / + background: repeating-conic-gradient(var(--app-hover) 0% 25%, transparent 0% 50%) 50% / 1rem 1rem; } @@ -225,12 +208,9 @@ font-family: var(--font-mono); font-size: 0.78125rem; line-height: 1.6; - color: var(--color-gray-800); + color: var(--app-fg); white-space: pre-wrap; word-break: break-all; } - :global(.dark) .source-view { - color: var(--color-gray-200); - } diff --git a/cptr/frontend/src/routes/+page.svelte b/cptr/frontend/src/routes/+page.svelte index 6aa03af..597e7ba 100644 --- a/cptr/frontend/src/routes/+page.svelte +++ b/cptr/frontend/src/routes/+page.svelte @@ -1530,11 +1530,11 @@ height: 100%; left: 50%; transform: translateX(-50%); - background: oklch(0.92 0 0); + background: var(--app-divider); } :global(.dark) .split-divider-h::before { - background: rgba(255, 255, 255, 0.06); + background: var(--app-divider); } .split-divider-v::before { @@ -1542,16 +1542,16 @@ width: 100%; top: 50%; transform: translateY(-50%); - background: oklch(0.92 0 0); + background: var(--app-divider); } :global(.dark) .split-divider-v::before { - background: rgba(255, 255, 255, 0.06); + background: var(--app-divider); } .split-divider:hover, .is-dragging .split-divider { - background: rgba(150, 150, 150, 0.12); + background: var(--app-active); } /* Drag state */ diff --git a/cptr/routers/chat.py b/cptr/routers/chat.py index ec6b195..378cac3 100644 --- a/cptr/routers/chat.py +++ b/cptr/routers/chat.py @@ -849,6 +849,93 @@ class ApproveRequest(BaseModel): approved: bool = True +class AskUserAnswerRequest(BaseModel): + call_id: str + answers: dict[str, str] + timed_out: bool = False + + +class AskUserNotPendingError(ValueError): + pass + + +async def resolve_ask_user( + app, + chat_id: str, + message_id: str, + call_id: str, + answers: dict[str, str] | None, + timed_out: bool, +): + chat = await Chat.get_by_id(chat_id) + msg = await ChatMessage.get_by_id(message_id) + if not chat or not msg or msg.chat_id != chat_id: + raise AskUserNotPendingError("ask_user request is no longer pending") + output = list(msg.output or []) + call = next( + ( + item + for item in output + if item.get("type") == "function_call" + and item.get("name") == "ask_user" + and item.get("call_id") == call_id + and item.get("status") == "pending" + ), + None, + ) + if not call: + raise AskUserNotPendingError("ask_user request is no longer pending") + if timed_out and now_ms() < int(call.get("expires_at") or 0): + raise AskUserNotPendingError("ask_user request has not timed out") + + from cptr.utils.chat_task import ask_user_answers, start_task + from cptr.utils.model_targets import resolve_model_target + from cptr.socket.main import emit_to_user + + result = ask_user_answers(call.get("arguments") or {}, None if timed_out else answers) + if timed_out: + result["timed_out"] = True + call["status"] = "completed" + call["timed_out"] = timed_out + output.append( + {"type": "function_call_output", "call_id": call_id, "output": json.dumps(result)} + ) + await ChatMessage.update(message_id, output=output, done=False) + await emit_to_user(chat.user_id, {"chat_id": chat_id, "message_id": message_id, "output": call}) + await emit_to_user( + chat.user_id, {"chat_id": chat_id, "message_id": message_id, "output": output[-1]} + ) + target = await resolve_model_target(msg.model or "", app.state) + start_task( + message_id=message_id, + chat_id=chat_id, + user_id=chat.user_id, + workspace=(chat.meta or {}).get("workspace", ""), + target=target, + ) + + +@router.post("/{chat_id}/messages/{message_id}/answer") +async def answer_ask_user( + chat_id: str, message_id: str, body: AskUserAnswerRequest, request: Request +): + """Resolve a pending Plan-mode ask_user request and resume the chat.""" + user_id = _get_user(request) + chat = await Chat.get_by_id(chat_id) + if not chat or chat.user_id != user_id: + raise HTTPException(404, "chat not found") + + try: + await resolve_ask_user( + request.app, chat_id, message_id, body.call_id, body.answers, body.timed_out + ) + except AskUserNotPendingError as exc: + raise HTTPException(409, str(exc)) from exc + except ValueError as exc: + raise HTTPException(422, str(exc)) from exc + return {"ok": True} + + @router.post("/{chat_id}/messages/{message_id}/approve") async def approve_tool(chat_id: str, message_id: str, body: ApproveRequest, request: Request): """Execute or reject a pending tool call, then continue.""" diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index 2b7a9c8..737c9c7 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -10,6 +10,7 @@ import logging import re import uuid +from typing import Any from cptr.events import EVENTS, publish_event from cptr.env import CHAT_MAX_ITERATIONS, CHAT_TOOL_COMMAND_MAX_CHARS, CHAT_TOOL_MAX_CHARS @@ -58,10 +59,103 @@ logger = logging.getLogger(__name__) +ASK_USER_NAME = "ask_user" +DEFAULT_AUTO_RESOLUTION_MS = 120_000 +ASK_USER_SCHEMA = { + "name": ASK_USER_NAME, + "description": "Ask the user one to three material planning questions and wait for their answers.", + "parameters": { + "type": "object", + "properties": { + "questions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "header": {"type": "string"}, + "question": {"type": "string"}, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": {"type": "string"}, + "description": {"type": "string"}, + }, + "required": ["label", "description"], + }, + }, + }, + "required": ["id", "header", "question", "options"], + }, + }, + "autoResolutionMs": {"type": "integer"}, + }, + "required": ["questions"], + }, +} + + +def validate_ask_user_request(arguments: dict[str, Any]) -> dict[str, Any]: + questions = arguments.get("questions") + if not isinstance(questions, list) or not 1 <= len(questions) <= 3: + raise ValueError("ask_user requires one to three questions") + ids: set[str] = set() + for question in questions: + if not isinstance(question, dict): + raise ValueError("each question must be an object") + if not all( + isinstance(question.get(key), str) and question[key].strip() + for key in ("id", "header", "question") + ): + raise ValueError("each question needs an id, header, and question") + if question["id"] in ids: + raise ValueError("question ids must be unique") + ids.add(question["id"]) + options = question.get("options") + if not isinstance(options, list) or not 2 <= len(options) <= 3: + raise ValueError("each question requires two or three options") + if any( + not isinstance(option, dict) + or not isinstance(option.get("label"), str) + or not option["label"].strip() + or not isinstance(option.get("description"), str) + or not option["description"].strip() + for option in options + ): + raise ValueError("each option needs a label and description") + timeout = arguments.get("autoResolutionMs", DEFAULT_AUTO_RESOLUTION_MS) + if ( + isinstance(timeout, bool) + or not isinstance(timeout, int) + or not 60_000 <= timeout <= 240_000 + ): + raise ValueError("autoResolutionMs must be between 60000 and 240000") + return {"questions": questions, "autoResolutionMs": timeout} + + +def ask_user_answers( + arguments: dict[str, Any], answers: dict[str, str] | None = None +) -> dict[str, Any]: + request = validate_ask_user_request(arguments) + result: dict[str, Any] = {} + for question in request["questions"]: + answer = question["options"][0]["label"] if answers is None else answers.get(question["id"]) + if not isinstance(answer, str) or not answer.strip(): + raise ValueError(f"missing answer for {question['id']}") + result[question["id"]] = {"answers": [answer.strip()]} + if answers is not None and set(answers) != set(result): + raise ValueError("answers must match the requested question ids") + return {"answers": result} + + PLAN_MODE_PROMPT = ( - "[Plan Mode] Research the codebase with read-only tools, then present your plan " - "using create_artifact. Then wait for an explicit approval message before using " - "tools or implementing." + "[Plan Mode] Research with read-only tools before planning. When a material decision " + "cannot be discovered, use ask_user with one to three questions, two to three options " + "each, and the recommended option first. Ask only after research and never alongside " + "another tool call. Then present your plan using create_artifact. Wait for an explicit " + "approval message before using write tools or implementing." ) SKILLS_CREATE_RE = re.compile(r"^/skills:create(?:\s+(.*))?$", re.IGNORECASE | re.DOTALL) @@ -1850,6 +1944,7 @@ async def _finish_reasoning_item(): tools = [t for t in tools if t["name"] not in {"delegate_task", "update_memory"}] # Inject create_artifact (only available in plan mode) tools.append(_fn_to_schema("create_artifact", create_artifact)) + tools.append(ASK_USER_SCHEMA) messages.append({"role": "user", "content": PLAN_MODE_PROMPT}) logger.info( "[task %s] plan mode active, %d tools available", message_id[:8], len(tools) @@ -2113,6 +2208,92 @@ async def _finish_reasoning_item(): "builtin_tools": builtin_tools, } + ask_user_calls = [tc for tc in pending_calls if tc["name"] == ASK_USER_NAME] + if ask_user_calls: + tc = ask_user_calls[0] + error = None + if len(pending_calls) != 1: + error = "Error: ask_user must be called by itself after research." + elif len(ask_user_calls) != 1: + error = "Error: only one ask_user call is allowed per turn." + else: + try: + arguments = validate_ask_user_request(tc["arguments"]) + except ValueError as exc: + error = f"Error: {exc}" + + if error: + item = { + "type": "function_call", + "id": str(uuid.uuid4()), + "call_id": tc["call_id"], + "fc_id": tc.get("id", ""), + "name": ASK_USER_NAME, + "arguments": tc["arguments"], + "status": "completed", + } + result_item = { + "type": "function_call_output", + "call_id": tc["call_id"], + "output": error, + } + output_items.extend((item, result_item)) + if flushed_item: + await emit(output=flushed_item) + await emit(output=item) + await emit(output=result_item) + _sync_state() + _append_batch_to_messages( + messages, + [(tc, error)], + provider, + reasoning_items=response_reasoning_items, + ) + new_messages_since += 2 + await _save_message( + "invalid ask_user", content=content, output=output_items + ) + continue + + expires_at = now_ms() + arguments["autoResolutionMs"] + item = { + "type": "function_call", + "id": str(uuid.uuid4()), + "call_id": tc["call_id"], + "fc_id": tc.get("id", ""), + "name": ASK_USER_NAME, + "arguments": arguments, + "status": "pending", + "expires_at": expires_at, + } + output_items.append(item) + await _save_message( + "pending ask_user", + content=content, + output=output_items, + done=False, + ) + if flushed_item: + await emit(output=flushed_item) + await emit(output=item) + _task_state.pop(message_id, None) + + async def auto_answer(): + await asyncio.sleep(arguments["autoResolutionMs"] / 1000) + from cptr.app import app + from cptr.routers.chat import AskUserNotPendingError, resolve_ask_user + + try: + await resolve_ask_user( + app, chat_id, message_id, tc["call_id"], None, True + ) + except AskUserNotPendingError: + pass + + asyncio.create_task(auto_answer()) + await emit(done=True) + return + # Check if any call needs approval needs_approval = None for tc in pending_calls: From 1e14d49de27650b89946129c2bcaacecfdc73e40 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 11 Jul 2026 19:46:58 -0500 Subject: [PATCH 02/13] refac --- .../src/lib/components/chat/ChatInput.svelte | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/cptr/frontend/src/lib/components/chat/ChatInput.svelte b/cptr/frontend/src/lib/components/chat/ChatInput.svelte index 23c48f0..b46c675 100644 --- a/cptr/frontend/src/lib/components/chat/ChatInput.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatInput.svelte @@ -160,7 +160,6 @@ } async function processFiles(files: File[]) { - if (!workspace) return; for (const file of files) { const id = Math.random().toString(36).substring(7); const isImage = file.type.startsWith('image/'); @@ -1471,16 +1470,14 @@ onmousedown={(e) => e.stopPropagation()} >
- {#if workspace} - { - if (files) processFiles(Array.from(files)); - }} - oncapture={(file) => { - processFiles([file]); - }} - /> - {/if} + { + if (files) processFiles(Array.from(files)); + }} + oncapture={(file) => { + processFiles([file]); + }} + /> {#if $planMode}
- {#each questions as question} + {#if question}
{question.header}
@@ -136,17 +125,35 @@
{/if}
- {/each} + {/if} - {#if pending} +
- {/if} + {#if questionIndex < questions.length - 1} + + {:else if pending} + + {/if} +
diff --git a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte index 8a791b6..df8ae88 100644 --- a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte +++ b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte @@ -8,7 +8,6 @@ import MessageTimestamp from './MessageTimestamp.svelte'; import ReasoningCollapsible from './ReasoningCollapsible.svelte'; import ToolCallCollapsible from './ToolCallCollapsible.svelte'; - import AskUserCard from './AskUserCard.svelte'; import { currentWorkspace, openFileTab } from '$lib/stores'; import { ttsConfigured, ttsEnabled } from '$lib/stores/audio'; import { tooltip } from '$lib/tooltip'; @@ -28,12 +27,6 @@ siblingTotal?: number; speaking?: boolean; onapprove: (messageId: string, callId: string, approved: boolean) => void; - onanswer: ( - messageId: string, - callId: string, - answers: Record, - timedOut: boolean - ) => void; onnavigate?: (direction: -1 | 1) => void; onfork?: () => void; onregenerate?: () => void; @@ -52,7 +45,6 @@ siblingTotal = 1, speaking = false, onapprove, - onanswer, onnavigate, onfork, onregenerate, @@ -270,19 +262,7 @@ index: number; } - interface AskUserItem { - type: 'ask_user_item'; - item: any; - output: any; - } - - type DisplayItem = - | ActivityGroup - | MessageItem - | ArtifactItem - | ImageItem - | FileItem - | AskUserItem; + type DisplayItem = ActivityGroup | MessageItem | ArtifactItem | ImageItem | FileItem; const outputText = $derived.by((): string => { return (output || []) @@ -363,10 +343,7 @@ for (const [index, item] of output.entries()) { if (item.type === 'function_call') { - if (item.name === 'ask_user') { - flushGroup(); - items.push({ type: 'ask_user_item', item, output: outputMap.get(item.call_id) }); - } else { + if (item.name !== 'ask_user') { ensureGroup(); currentGroup!.entries.push(item); currentGroup!.calls.push(item); @@ -572,14 +549,6 @@ {/if} - {:else if displayItem.type === 'ask_user_item'} - {:else if displayItem.type === 'activity_group'} {#if displayItem.entries.length === 1} {@const item = displayItem.entries[0]} diff --git a/cptr/frontend/src/lib/components/chat/ChatInput.svelte b/cptr/frontend/src/lib/components/chat/ChatInput.svelte index b46c675..ae56f27 100644 --- a/cptr/frontend/src/lib/components/chat/ChatInput.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatInput.svelte @@ -27,6 +27,7 @@ import DictateButton from './DictateButton.svelte'; import QueuedMessageItem from './QueuedMessageItem.svelte'; import Tasks from './Tasks.svelte'; + import AskUserCard from './AskUserCard.svelte'; import Icon from '../Icon.svelte'; import { planMode } from '$lib/stores'; import { @@ -71,6 +72,7 @@ placeholder?: string; contextUsage?: ContextUsage | null; tasks?: ChatTask[]; + askUser?: any; queuedMessages?: { id: string; content: string }[]; hasChatContent?: boolean; onsend: () => void; @@ -80,6 +82,12 @@ onstatus?: () => void; onskillslist?: () => void; oncancel?: () => void; + onaskuseranswer?: ( + messageId: string, + callId: string, + answers: Record, + timedOut: boolean + ) => void; onqueuesendnow?: (id: string) => void; onqueueedit?: (id: string) => void; onqueuedelete?: (id: string) => void; @@ -93,6 +101,7 @@ placeholder = 'Message...', contextUsage = null, tasks = [], + askUser = null, queuedMessages = [], hasChatContent = false, onsend, @@ -102,6 +111,7 @@ onstatus, onskillslist, oncancel, + onaskuseranswer, onqueuesendnow, onqueueedit, onqueuedelete @@ -1108,6 +1118,18 @@ }} role="presentation" > + {#if askUser && onaskuseranswer} +
+ +
+ {/if} + {#if tasks.length > 0}
diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte index b9a8365..90edf70 100644 --- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte @@ -77,15 +77,45 @@ workspace?: string; chatId?: string; tabId?: string; + active?: boolean; ontabupdate?: (tabId: string, chatId: string, label: string) => void; onopenchat?: (chatId?: string) => void; } - let { workspace = '', chatId: initialChatId, tabId, ontabupdate, onopenchat }: Props = $props(); + let { + workspace = '', + chatId: initialChatId, + tabId, + active = true, + ontabupdate, + onopenchat + }: Props = $props(); let inputText = $state(''); let chatId = $state(initialChatId ?? null); let selectedModel = $state(''); let allMessages = $state([]); + const pendingAskUser = $derived.by(() => { + for (const message of [...allMessages].reverse()) { + const item = message.output?.find( + (output: any) => + output.type === 'function_call' && + output.name === 'ask_user' && + output.status === 'pending' + ); + if (item) { + return { + item, + output: message.output?.find( + (output: any) => + output.type === 'function_call_output' && output.call_id === item.call_id + ), + chatId, + messageId: message.id + }; + } + } + return null; + }); let currentMessageId = $state(null); let contextUsage = $state(null); let chatTasks = $state([]); @@ -699,6 +729,22 @@ return () => unregisterStreamingChat(chatId!); }); + $effect(() => { + if (!chatId || !tabId) return; + const send = () => + socketStore + .getSocket() + ?.emit('chat:view', { chat_id: chatId, view_id: tabId, visible: active }); + send(); + const off = socketStore.on('connect', send); + return () => { + off(); + socketStore + .getSocket() + ?.emit('chat:view', { chat_id: chatId, view_id: tabId, visible: false }); + }; + }); + // ── Auto-scroll ───────────────────────────────────────────── function scrollToBottom() { @@ -1595,6 +1641,8 @@ {workspace} placeholder={$t('chat.placeholder', { name: workspaceDisplayName })} tasks={chatTasks} + askUser={pendingAskUser} + onaskuseranswer={handleAskUserAnswer} onsend={send} onplan={handlePlanCommand} {queuedMessages} @@ -1668,7 +1716,6 @@ onedit={(c, o, submit) => handleEditMessage(msg.id, c, o, submit)} onspeak={() => speakMessage(msg.id)} onapprove={handleApprove} - onanswer={handleAskUserAnswer} /> {/if} {/each} @@ -1716,6 +1763,8 @@ {contextUsage} {hasChatContent} tasks={chatTasks} + askUser={pendingAskUser} + onaskuseranswer={handleAskUserAnswer} onsend={send} oncompact={handleManualCompact} onfork={handleForkChat} diff --git a/cptr/frontend/src/routes/+page.svelte b/cptr/frontend/src/routes/+page.svelte index 597e7ba..22580fd 100644 --- a/cptr/frontend/src/routes/+page.svelte +++ b/cptr/frontend/src/routes/+page.svelte @@ -1042,6 +1042,7 @@ ? undefined : tab.path} tabId={tab.id} + active={tab.id === homePane.activeTabId} ontabupdate={(tabId, chatId, label) => updateHomeChatTab(tabId, chatId, label, homePane.id)} onopenchat={(chatId) => openHomeChat(chatId, homePane.id)} @@ -1373,6 +1374,7 @@ ? undefined : tab.path} tabId={tab.id} + active={tab.id === group.activeTabId} />
{/each} diff --git a/cptr/socket/main.py b/cptr/socket/main.py index 0108d74..c22bd63 100644 --- a/cptr/socket/main.py +++ b/cptr/socket/main.py @@ -16,6 +16,8 @@ # user_id → set of connected sids _user_sids: dict[str, set[str]] = {} +# (user_id, chat_id) → visible panel instances +_chat_view_sids: dict[tuple[str, str], set[tuple[str, str]]] = {} @sio.on("connect") @@ -47,6 +49,32 @@ async def on_disconnect(sid): _user_sids[uid].discard(sid) if not _user_sids[uid]: del _user_sids[uid] + for key, views in list(_chat_view_sids.items()): + views_copy = {view for view in views if view[0] != sid} + if views_copy: + _chat_view_sids[key] = views_copy + else: + del _chat_view_sids[key] + + +@sio.on("chat:view") +async def on_chat_view(sid, data): + """Track which chat panels are currently visible for timeout pausing.""" + session = await sio.get_session(sid) + user_id = session.get("user_id") + chat_id = data.get("chat_id") if isinstance(data, dict) else None + view_id = data.get("view_id") if isinstance(data, dict) else None + if not user_id or not isinstance(chat_id, str) or not isinstance(view_id, str): + return + + views = _chat_view_sids.setdefault((user_id, chat_id), set()) + view = (sid, view_id) + if data.get("visible"): + views.add(view) + else: + views.discard(view) + if not views: + del _chat_view_sids[(user_id, chat_id)] async def emit_to_user(user_id: str, data: dict): @@ -55,6 +83,10 @@ async def emit_to_user(user_id: str, data: dict): await sio.emit("events:chat", data, to=sid) +def is_chat_visible(user_id: str, chat_id: str) -> bool: + return bool(_chat_view_sids.get((user_id, chat_id))) + + def is_user_active(user_id: str) -> bool: """Return True when the user has at least one connected app session.""" return bool(_user_sids.get(user_id)) diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index 737c9c7..35e90fd 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -2279,7 +2279,13 @@ async def _finish_reasoning_item(): _task_state.pop(message_id, None) async def auto_answer(): - await asyncio.sleep(arguments["autoResolutionMs"] / 1000) + from cptr.socket.main import is_chat_visible + + remaining = arguments["autoResolutionMs"] / 1000 + while remaining > 0: + await asyncio.sleep(1) + if not is_chat_visible(user_id, chat_id): + remaining -= 1 from cptr.app import app from cptr.routers.chat import AskUserNotPendingError, resolve_ask_user From bfddf4aa3889dfc9117ffaa2c632c6ff267a6217 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 11 Jul 2026 20:06:57 -0500 Subject: [PATCH 04/13] refac --- .../lib/components/chat/AskUserCard.svelte | 126 +++++++++++++----- 1 file changed, 89 insertions(+), 37 deletions(-) diff --git a/cptr/frontend/src/lib/components/chat/AskUserCard.svelte b/cptr/frontend/src/lib/components/chat/AskUserCard.svelte index ee2cb0e..4bca156 100644 --- a/cptr/frontend/src/lib/components/chat/AskUserCard.svelte +++ b/cptr/frontend/src/lib/components/chat/AskUserCard.svelte @@ -21,13 +21,16 @@ const questions = $derived(Array.isArray(request.questions) ? request.questions : []); const question = $derived(questions[questionIndex]); const pending = $derived(item.status === 'pending'); - const canSubmit = $derived( - questions.length > 0 && + function hasAnswers(answers: Record) { + return ( + questions.length > 0 && questions.every((question: any) => { - const choice = selections[question.id]; + const choice = answers[question.id]; return choice && (choice !== '__other__' || otherAnswers[question.id]?.trim()); }) - ); + ); + } + const canSubmit = $derived(hasAnswers(selections)); const resolved = $derived.by(() => { try { return JSON.parse(pairedOutput?.output || '{}'); @@ -36,11 +39,11 @@ } }); - function submit(timedOut = false) { - if (!chatId || (!canSubmit && !timedOut)) return; + function submit(timedOut = false, selected = selections) { + if (!chatId || (!hasAnswers(selected) && !timedOut)) return; const answers: Record = {}; for (const question of questions) { - const choice = selections[question.id]; + const choice = selected[question.id]; answers[question.id] = timedOut ? question.options[0].label : choice === '__other__' @@ -50,68 +53,117 @@ submitting = true; onanswer(messageId, item.call_id, answers, timedOut); } + + function selectAnswer(questionId: string, answer: string) { + const selected = { ...selections, [questionId]: answer }; + selections = selected; + if (answer === '__other__') return; + if (questionIndex < questions.length - 1) { + questionIndex += 1; + } else if (hasAnswers(selected)) { + submit(false, selected); + } + } -
-
-
Planning question
+
+
+
+ Planning question +
{#if pending}
- {questionIndex + 1} / {questions.length} · paused while this chat is visible + {questionIndex + 1} of {questions.length} · paused while visible
{:else if item.timed_out || resolved.timed_out} -
Used recommendations
+
Recommendations used
{/if}
-
+
{#if question} -
+
-
{question.header}
-
{question.question}
+
+ {question.header} +
+
+ {question.question} +
{#if pending} -
+
{#each question.options || [] as option, optionIndex} {/each} {#if selections[question.id] === '__other__'} @@ -120,17 +172,17 @@ {/if}
{:else} -
+
{resolved.answers?.[question.id]?.answers?.join(', ') || 'No answer recorded'}
{/if}
{/if} -
+