feat: persist AI conversations and add chat history (#661)

* feat: persist AI conversations and add chat history

Store transcripts and attachment previews in IndexedDB, separate conversation history from transport lifetime, and add document-aware switching with shared Storybook coverage. Preserve interrupted activity and guard stale writes and async switches.

* test: group chat history tests by domain

* feat: simplify chat history navigation and diagnostics

Use a compact header with searchable document-scoped history and a correctly anchored conversation menu. Move diagnostic copying into the menu with copy-result feedback, and separate Storybook fixtures from composition.

* fix: address chat history review findings

* test: cover harness shutdown and restored chat scrolling

* fix: preserve Portless proxy port in MCP routes

* docs: clarify UI animation conventions

* feat: configure reasoning display and animate disclosure

* fix: separate transcript following from reasoning disclosure

* fix: restore selected chat after document recovery

* refactor: separate chat history persistence and sessions

* style: format reasoning story imports
This commit is contained in:
Danila Poyarkov 2026-09-08 11:19:07 +03:00 committed by GitHub
parent 9dac52da13
commit b4e479d9d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 3078 additions and 373 deletions

View file

@ -220,6 +220,14 @@ Keep responsibilities distinct: engine tests cover state contracts, Playwright b
- Binding-aware fields detach/mutate only on the first value change; opening/focusing is non-destructive.
- Preserve nearby interaction gotchas when refactoring: splitter handles, NumberField pointer ownership, section dragging, panel containment, and number-spinner styling.
### Animations
- Use Tailwind transitions and `tw-animate-css` for simple visual state changes and enter/exit animations. Use the existing `motion-v` dependency for gesture-driven motion, coordinated layout changes, and springs; do not add another animation library.
- Use Reka state attributes and measured CSS variables for collapsibles. The utilities are `animate-collapsible-down` and `animate-collapsible-up`; keep padding and borders inside the animated height wrapper so they do not snap during collapse.
- Respect `prefers-reduced-motion` in both CSS and Motion. Disable or simplify nonessential motion while preserving state changes and interaction feedback.
- Keep reusable animation styling in the owning theme and share repeated duration/easing values rather than scattering timing constants across components.
- Verify opening and closing, interrupted transitions, reduced motion, and scroll behavior. Expanding historical chat content must not force the transcript to the bottom.
## File format
- Kiwi schema/runtime/codec/container helpers live in `@open-pencil/kiwi`; complete archive parsing and SceneGraph conversion live in `@open-pencil/fig`; Core owns format-neutral orchestration, runtime fonts/workers, and thumbnails.

View file

@ -4,6 +4,8 @@
### Added
- Save AI conversations and attachment previews locally, switch between chats, rename or delete them, and browse saved transcripts across documents. Choose whether reasoning stays collapsed, expands while thinking, or stays expanded, with animated disclosure controls that respect reduced motion.
- Add a searchable command palette for editor and application actions.
- Search current AI provider catalogs from model pickers, with curated recommendations, recent compatible models, and offline fallbacks.
- Render triangle and line arrow stroke caps on lines and open vector paths, and choose them from the stroke cap picker.

View file

@ -115,5 +115,37 @@
"recommendedModels": "Empfohlen",
"latestModels": "Neueste",
"allModels": "Alle Modelle",
"latest": "Neu"
"latest": "Neu",
"chatHistory": "Chatverlauf",
"newChat": "Neuer Chat",
"allChats": "Alle Chats",
"renameChat": "Umbenennen",
"deleteChat": "Löschen",
"chatTitle": "Chatname",
"saveChatTitle": "Speichern",
"cancelChatAction": "Abbrechen",
"deleteChatConfirmation": "Diesen Chat endgültig löschen?",
"chatDocumentHint": "Öffne das Dokument des Chats, um fortzufahren.",
"chatReadOnly": "Dieser Chat gehört zu einem anderen Dokument. Öffne es, um fortzufahren.",
"chatAgentReadOnly": "Der Agentenverlauf wurde gespeichert, aber die Sitzung kann hier noch nicht fortgesetzt werden. Starte einen neuen Chat.",
"chatStorageFailed": "Der Chatverlauf konnte nicht gespeichert werden. Lass dieses Fenster geöffnet und versuche es erneut.",
"chatHistoryFailed": "Der Chatverlauf konnte nicht aktualisiert werden. Versuche es erneut.",
"chatInterrupted": "Diese Antwort wurde unterbrochen.",
"searchChats": "Unterhaltungen suchen…",
"chatScope": "Unterhaltungsbereich",
"thisDocument": "Dieses Dokument",
"allDocuments": "Alle Dokumente",
"noChatsFound": "Keine Unterhaltungen gefunden.",
"chatActions": "Unterhaltungsaktionen",
"copyDiagnosticLog": "Diagnoseprotokoll kopieren",
"diagnosticCopied": "Diagnoseprotokoll kopiert.",
"diagnosticCopyFailed": "Das Diagnoseprotokoll konnte nicht kopiert werden. Prüfe die Berechtigungen für die Zwischenablage und versuche es erneut.",
"continueChat": "Fortfahren",
"copyACPLog": "ACP-Protokoll kopieren",
"chatSettings": "Chat",
"reasoningDisplay": "Anzeige der Überlegungen",
"reasoningCollapsed": "Standardmäßig eingeklappt",
"reasoningWhileThinking": "Beim Nachdenken ausklappen",
"reasoningExpanded": "Standardmäßig ausgeklappt",
"jumpToLatest": "Zum neuesten Beitrag"
}

View file

@ -115,5 +115,37 @@
"connectProvider": "Conecta un proveedor de IA para empezar a chatear.",
"getProviderAPIKey": "Obtener una clave API de {provider} →",
"provider": "Proveedor de IA",
"modelsTitle": "Modelos"
"modelsTitle": "Modelos",
"chatHistory": "Historial de chats",
"newChat": "Nuevo chat",
"allChats": "Todos los chats",
"renameChat": "Renombrar",
"deleteChat": "Eliminar",
"chatTitle": "Título del chat",
"saveChatTitle": "Guardar",
"cancelChatAction": "Cancelar",
"deleteChatConfirmation": "¿Eliminar este chat permanentemente?",
"chatDocumentHint": "Abre el documento del chat para continuar.",
"chatReadOnly": "Este chat pertenece a otro documento. Ábrelo para continuar.",
"chatAgentReadOnly": "La conversación del agente está guardada, pero todavía no se puede reanudar su sesión aquí. Inicia un nuevo chat.",
"chatStorageFailed": "No se pudo guardar el historial. Mantén esta ventana abierta e inténtalo de nuevo.",
"chatHistoryFailed": "No se pudo actualizar el historial. Inténtalo de nuevo.",
"chatInterrupted": "Esta respuesta se interrumpió.",
"searchChats": "Buscar conversaciones…",
"chatScope": "Ámbito de conversaciones",
"thisDocument": "Este documento",
"allDocuments": "Todos los documentos",
"noChatsFound": "No se encontraron conversaciones.",
"chatActions": "Acciones de la conversación",
"copyDiagnosticLog": "Copiar registro de diagnóstico",
"diagnosticCopied": "Registro de diagnóstico copiado.",
"diagnosticCopyFailed": "No se pudo copiar el registro de diagnóstico. Comprueba los permisos del portapapeles e inténtalo de nuevo.",
"continueChat": "Continuar",
"copyACPLog": "Copiar registro ACP",
"chatSettings": "Chat",
"reasoningDisplay": "Visualización del razonamiento",
"reasoningCollapsed": "Contraído por defecto",
"reasoningWhileThinking": "Expandir al razonar",
"reasoningExpanded": "Expandido por defecto",
"jumpToLatest": "Ir al último mensaje"
}

View file

@ -115,5 +115,37 @@
"recommendedModels": "Recommandés",
"latestModels": "Plus récents",
"allModels": "Tous les modèles",
"latest": "Nouveau"
"latest": "Nouveau",
"chatHistory": "Historique des discussions",
"newChat": "Nouvelle discussion",
"allChats": "Toutes les discussions",
"renameChat": "Renommer",
"deleteChat": "Supprimer",
"chatTitle": "Titre de la discussion",
"saveChatTitle": "Enregistrer",
"cancelChatAction": "Annuler",
"deleteChatConfirmation": "Supprimer définitivement cette discussion ?",
"chatDocumentHint": "Ouvrez le document de la discussion pour continuer.",
"chatReadOnly": "Cette discussion appartient à un autre document. Ouvrez-le pour continuer.",
"chatAgentReadOnly": "La conversation avec l’agent est enregistrée, mais sa session ne peut pas encore reprendre ici. Commencez une nouvelle discussion.",
"chatStorageFailed": "Impossible d’enregistrer l’historique. Gardez cette fenêtre ouverte et réessayez.",
"chatHistoryFailed": "Impossible de mettre à jour l’historique. Réessayez.",
"chatInterrupted": "Cette réponse a été interrompue.",
"searchChats": "Rechercher des discussions…",
"chatScope": "Portée des discussions",
"thisDocument": "Ce document",
"allDocuments": "Tous les documents",
"noChatsFound": "Aucune discussion trouvée.",
"chatActions": "Actions de la discussion",
"copyDiagnosticLog": "Copier le journal de diagnostic",
"diagnosticCopied": "Journal de diagnostic copié.",
"diagnosticCopyFailed": "Impossible de copier le journal de diagnostic. Vérifiez les autorisations du presse-papiers et réessayez.",
"continueChat": "Continuer",
"copyACPLog": "Copier le journal ACP",
"chatSettings": "Discussion",
"reasoningDisplay": "Affichage du raisonnement",
"reasoningCollapsed": "Replié par défaut",
"reasoningWhileThinking": "Déplier pendant le raisonnement",
"reasoningExpanded": "Déplié par défaut",
"jumpToLatest": "Aller au dernier message"
}

View file

@ -115,5 +115,37 @@
"connectProvider": "Collega un provider AI per iniziare a chattare.",
"getProviderAPIKey": "Ottieni una chiave API {provider} →",
"provider": "Provider AI",
"modelsTitle": "Modelli"
"modelsTitle": "Modelli",
"chatHistory": "Cronologia chat",
"newChat": "Nuova chat",
"allChats": "Tutte le chat",
"renameChat": "Rinomina",
"deleteChat": "Elimina",
"chatTitle": "Titolo della chat",
"saveChatTitle": "Salva",
"cancelChatAction": "Annulla",
"deleteChatConfirmation": "Eliminare definitivamente questa chat?",
"chatDocumentHint": "Apri il documento della chat per continuare.",
"chatReadOnly": "Questa chat appartiene a un altro documento. Aprilo per continuare.",
"chatAgentReadOnly": "La conversazione con l’agente è salvata, ma non è ancora possibile riprendere la sessione qui. Avvia una nuova chat.",
"chatStorageFailed": "Impossibile salvare la cronologia. Tieni aperta questa finestra e riprova.",
"chatHistoryFailed": "Impossibile aggiornare la cronologia. Riprova.",
"chatInterrupted": "Questa risposta è stata interrotta.",
"searchChats": "Cerca conversazioni…",
"chatScope": "Ambito delle conversazioni",
"thisDocument": "Questo documento",
"allDocuments": "Tutti i documenti",
"noChatsFound": "Nessuna conversazione trovata.",
"chatActions": "Azioni della conversazione",
"copyDiagnosticLog": "Copia registro diagnostico",
"diagnosticCopied": "Registro diagnostico copiato.",
"diagnosticCopyFailed": "Impossibile copiare il registro diagnostico. Controlla i permessi degli appunti e riprova.",
"continueChat": "Continua",
"copyACPLog": "Copia registro ACP",
"chatSettings": "Chat",
"reasoningDisplay": "Visualizzazione del ragionamento",
"reasoningCollapsed": "Compresso per impostazione predefinita",
"reasoningWhileThinking": "Espandi durante il ragionamento",
"reasoningExpanded": "Espanso per impostazione predefinita",
"jumpToLatest": "Vai all’ultimo messaggio"
}

View file

@ -115,5 +115,37 @@
"connectProvider": "チャットを始めるには、AIプロバイダーを接続してください。",
"getProviderAPIKey": "{provider} からAPIキーを取得 →",
"provider": "AIプロバイダー",
"modelsTitle": "モデル"
"modelsTitle": "モデル",
"chatHistory": "チャット履歴",
"newChat": "新しいチャット",
"allChats": "すべてのチャット",
"renameChat": "名前を変更",
"deleteChat": "削除",
"chatTitle": "チャットのタイトル",
"saveChatTitle": "保存",
"cancelChatAction": "キャンセル",
"deleteChatConfirmation": "このチャットを完全に削除しますか?",
"chatDocumentHint": "続けるにはチャットのドキュメントを開いてください。",
"chatReadOnly": "このチャットは別のドキュメントに属しています。続けるにはそのドキュメントを開いてください。",
"chatAgentReadOnly": "エージェントとの会話は保存されていますが、ここではまだセッションを再開できません。新しいチャットを開始してください。",
"chatStorageFailed": "チャット履歴を保存できませんでした。このウィンドウを開いたまま、もう一度お試しください。",
"chatHistoryFailed": "チャット履歴を更新できませんでした。もう一度お試しください。",
"chatInterrupted": "この応答は中断されました。",
"searchChats": "会話を検索…",
"chatScope": "会話の範囲",
"thisDocument": "このドキュメント",
"allDocuments": "すべてのドキュメント",
"noChatsFound": "会話が見つかりません。",
"chatActions": "会話の操作",
"copyDiagnosticLog": "診断ログをコピー",
"diagnosticCopied": "診断ログをコピーしました。",
"diagnosticCopyFailed": "診断ログをコピーできませんでした。クリップボードの権限を確認して、もう一度お試しください。",
"continueChat": "続ける",
"copyACPLog": "ACPログをコピー",
"chatSettings": "チャット",
"reasoningDisplay": "推論の表示",
"reasoningCollapsed": "既定で折りたたむ",
"reasoningWhileThinking": "思考中に展開",
"reasoningExpanded": "既定で展開",
"jumpToLatest": "最新へ移動"
}

View file

@ -115,5 +115,37 @@
"recommendedModels": "Polecane",
"latestModels": "Najnowsze",
"allModels": "Wszystkie modele",
"latest": "Nowy"
"latest": "Nowy",
"chatHistory": "Historia czatów",
"newChat": "Nowy czat",
"allChats": "Wszystkie czaty",
"renameChat": "Zmień nazwę",
"deleteChat": "Usuń",
"chatTitle": "Tytuł czatu",
"saveChatTitle": "Zapisz",
"cancelChatAction": "Anuluj",
"deleteChatConfirmation": "Trwale usunąć ten czat?",
"chatDocumentHint": "Otwórz dokument czatu, aby kontynuować.",
"chatReadOnly": "Ten czat należy do innego dokumentu. Otwórz go, aby kontynuować.",
"chatAgentReadOnly": "Rozmowa z agentem została zapisana, ale nie można tu jeszcze wznowić jego sesji. Rozpocznij nowy czat.",
"chatStorageFailed": "Nie udało się zapisać historii. Nie zamykaj tego okna i spróbuj ponownie.",
"chatHistoryFailed": "Nie udało się zaktualizować historii. Spróbuj ponownie.",
"chatInterrupted": "Ta odpowiedź została przerwana.",
"searchChats": "Szukaj rozmów…",
"chatScope": "Zakres rozmów",
"thisDocument": "Ten dokument",
"allDocuments": "Wszystkie dokumenty",
"noChatsFound": "Nie znaleziono rozmów.",
"chatActions": "Działania rozmowy",
"copyDiagnosticLog": "Kopiuj dziennik diagnostyczny",
"diagnosticCopied": "Skopiowano dziennik diagnostyczny.",
"diagnosticCopyFailed": "Nie udało się skopiować dziennika diagnostycznego. Sprawdź uprawnienia schowka i spróbuj ponownie.",
"continueChat": "Kontynuuj",
"copyACPLog": "Kopiuj dziennik ACP",
"chatSettings": "Czat",
"reasoningDisplay": "Wyświetlanie rozumowania",
"reasoningCollapsed": "Domyślnie zwinięte",
"reasoningWhileThinking": "Rozwijaj podczas rozumowania",
"reasoningExpanded": "Domyślnie rozwinięte",
"jumpToLatest": "Przejdź do najnowszej wiadomości"
}

View file

@ -115,5 +115,37 @@
"recommendedModels": "Рекомендуемые",
"latestModels": "Новые",
"allModels": "Все модели",
"latest": "Новая"
"latest": "Новая",
"chatHistory": "История чатов",
"newChat": "Новый чат",
"allChats": "Все чаты",
"renameChat": "Переименовать",
"deleteChat": "Удалить",
"chatTitle": "Название чата",
"saveChatTitle": "Сохранить",
"cancelChatAction": "Отмена",
"deleteChatConfirmation": "Удалить этот чат навсегда?",
"chatDocumentHint": "Откройте документ чата, чтобы продолжить.",
"chatReadOnly": "Этот чат относится к другому документу. Откройте его, чтобы продолжить.",
"chatAgentReadOnly": "Переписка с агентом сохранена, но восстановление его сессии пока недоступно. Начните новый чат.",
"chatStorageFailed": "Не удалось сохранить историю чата. Не закрывайте окно и попробуйте ещё раз.",
"chatHistoryFailed": "Не удалось обновить историю чатов. Попробуйте ещё раз.",
"chatInterrupted": "Этот ответ был прерван.",
"searchChats": "Поиск по чатам…",
"chatScope": "Область поиска чатов",
"thisDocument": "Этот документ",
"allDocuments": "Все документы",
"noChatsFound": "Чаты не найдены.",
"chatActions": "Действия с чатом",
"copyDiagnosticLog": "Копировать диагностический журнал",
"diagnosticCopied": "Диагностический журнал скопирован.",
"diagnosticCopyFailed": "Не удалось скопировать диагностический журнал. Проверьте разрешения буфера обмена и повторите попытку.",
"continueChat": "Продолжить",
"copyACPLog": "Копировать журнал ACP",
"chatSettings": "Чат",
"reasoningDisplay": "Отображение рассуждений",
"reasoningCollapsed": "Свёрнуто по умолчанию",
"reasoningWhileThinking": "Разворачивать во время размышлений",
"reasoningExpanded": "Развёрнуто по умолчанию",
"jumpToLatest": "К последнему сообщению"
}

View file

@ -115,5 +115,37 @@
"connectProvider": "连接 AI 提供商以开始聊天。",
"getProviderAPIKey": "获取 {provider} API 密钥 →",
"provider": "AI 提供商",
"modelsTitle": "模型"
"modelsTitle": "模型",
"chatHistory": "聊天记录",
"newChat": "新建聊天",
"allChats": "所有聊天",
"renameChat": "重命名",
"deleteChat": "删除",
"chatTitle": "聊天标题",
"saveChatTitle": "保存",
"cancelChatAction": "取消",
"deleteChatConfirmation": "永久删除此聊天?",
"chatDocumentHint": "打开聊天所属的文档以继续。",
"chatReadOnly": "此聊天属于另一个文档。打开该文档以继续。",
"chatAgentReadOnly": "与代理的对话已保存,但暂时无法在此恢复会话。请新建聊天。",
"chatStorageFailed": "无法保存聊天记录。请保持此窗口打开并重试。",
"chatHistoryFailed": "无法更新聊天记录。请重试。",
"chatInterrupted": "此回复已中断。",
"searchChats": "搜索对话…",
"chatScope": "对话范围",
"thisDocument": "此文档",
"allDocuments": "所有文档",
"noChatsFound": "未找到对话。",
"chatActions": "对话操作",
"copyDiagnosticLog": "复制诊断日志",
"diagnosticCopied": "诊断日志已复制。",
"diagnosticCopyFailed": "无法复制诊断日志。请检查剪贴板权限并重试。",
"continueChat": "继续",
"copyACPLog": "复制 ACP 日志",
"chatSettings": "聊天",
"reasoningDisplay": "推理显示",
"reasoningCollapsed": "默认折叠",
"reasoningWhileThinking": "思考时展开",
"reasoningExpanded": "默认展开",
"jumpToLatest": "跳转到最新消息"
}

View file

@ -3,6 +3,40 @@ import { params } from '@nanostores/i18n'
import { i18n } from '#vue/i18n/create'
export const aiMessageDefaults = {
jumpToLatest: 'Jump to latest',
chatSettings: 'Chat',
reasoningDisplay: 'Reasoning display',
reasoningCollapsed: 'Collapsed by default',
reasoningWhileThinking: 'Expand while thinking',
reasoningExpanded: 'Expanded by default',
continueChat: 'Continue',
copyACPLog: 'Copy ACP log',
diagnosticCopied: 'Diagnostic log copied.',
diagnosticCopyFailed:
'Could not copy the diagnostic log. Check clipboard permissions and try again.',
copyDiagnosticLog: 'Copy diagnostic log',
searchChats: 'Search conversations…',
chatScope: 'Conversation scope',
thisDocument: 'This document',
allDocuments: 'All documents',
noChatsFound: 'No conversations found.',
chatActions: 'Conversation actions',
chatHistory: 'Conversation history',
newChat: 'New chat',
allChats: 'All chats',
renameChat: 'Rename',
deleteChat: 'Delete',
chatTitle: 'Conversation title',
saveChatTitle: 'Save',
cancelChatAction: 'Cancel',
deleteChatConfirmation: 'Delete this conversation permanently?',
chatDocumentHint: 'Open a chat’s document to continue it.',
chatReadOnly: 'This conversation belongs to another document. Open its document to continue.',
chatAgentReadOnly:
'This agent transcript is saved, but its session cannot be resumed here yet. Start a new chat to continue working.',
chatStorageFailed: 'Chat history could not be saved. Keep this window open and try again.',
chatHistoryFailed: 'Could not update chat history. Try again.',
chatInterrupted: 'This response was interrupted.',
connectProvider: 'Connect an AI provider to start chatting.',
connectionTestSuccess: 'Connected successfully. Model is reachable.',
connectionTestMissingAPIKey: 'Enter an API key before testing.',

View file

@ -0,0 +1,24 @@
import type { ChatTransport, UIMessage } from 'ai'
/** Preserve partial tool activity in history but omit unpaired calls from new requests. */
export function resumableTransport(transport: ChatTransport<UIMessage>): ChatTransport<UIMessage> {
return {
reconnectToStream: (options) => transport.reconnectToStream(options),
sendMessages: (options) =>
transport.sendMessages({
...options,
messages: options.messages
.map((message) => ({
...message,
parts: message.parts.filter(
(part) =>
!('toolCallId' in part) ||
part.state === 'output-available' ||
part.state === 'output-error' ||
part.state === 'output-denied'
)
}))
.filter((message) => message.parts.length > 0)
})
}
}

View file

@ -0,0 +1,210 @@
import type { UIMessage } from 'ai'
import { ref, shallowRef } from 'vue'
import { chatDocumentId, resolveChatDocumentId, type ChatDocumentEditor } from './document'
import { createConversationStore } from './idb'
import { restoreMessages } from './messages'
import { createHistoryPersistence } from './persistence'
import { createHistorySession } from './session'
import type {
Conversation,
ConversationMeta,
ConversationStore,
HistoryChat,
HistoryRuntime
} from './types'
/** Coordinates document ownership and serialized public conversation actions. */
export function createConversationHistory<TChat extends HistoryChat>(
runtime: HistoryRuntime<TChat>,
store: ConversationStore = createConversationStore()
) {
const current = shallowRef<Conversation | null>(null)
const messages = shallowRef<UIMessage[]>([])
const conversations = ref<ConversationMeta[]>([])
const readOnly = ref(false)
const busy = ref(false)
let ownerRecoveryId: string | null = null
let owner: ChatDocumentEditor | null = null
let operation: Promise<unknown> = Promise.resolve()
const session = createHistorySession(() => runtime.resetChat())
const { flush, storageError } = createHistoryPersistence({
store,
current,
messages,
readOnly,
refresh,
content: () => ({
messages: session.chat?.messages ?? messages.value,
interrupted: session.interrupted
})
})
function serialize<T>(run: () => Promise<T>): Promise<T> {
const next = operation.then(async () => {
busy.value = true
try {
return await run()
} finally {
busy.value = false
}
})
operation = next.catch(() => undefined)
return next
}
async function refresh() {
conversations.value = await store.list()
}
function detach() {
return session.detach(flush)
}
async function activate(conversation: Conversation) {
owner = runtime.getEditor()
ownerRecoveryId = owner.getRecoveryId()
session.restoreInterrupted(conversation.interrupted)
readOnly.value = conversation.documentId !== chatDocumentId(owner)
current.value = conversation
messages.value = restoreMessages(conversation.messages)
if (conversation.messages.length || conversation.titleSource !== 'fallback') {
await store.select(conversation.documentId, conversation.id)
}
}
async function createDraft() {
const editor = runtime.getEditor()
const now = new Date().toISOString()
await activate({
id: crypto.randomUUID(),
documentId: await resolveChatDocumentId(editor, store),
documentName: editor.state.documentName,
title: '',
titleSource: 'fallback',
createdAt: now,
updatedAt: now,
profileId: runtime.profileId(),
backend: runtime.backend(),
interrupted: false,
messages: []
})
await refresh()
}
function isIdentityChange(editor: ChatDocumentEditor, documentId: string) {
return (
owner === editor &&
ownerRecoveryId === editor.getRecoveryId() &&
current.value &&
!readOnly.value &&
current.value.documentId !== documentId
)
}
async function reassignDocument(editor: ChatDocumentEditor, documentId: string) {
if (!current.value) return
await flush()
await store.reassignDocument(current.value.documentId, documentId, editor.state.documentName)
current.value = { ...current.value, documentId, documentName: editor.state.documentName }
await refresh()
}
async function loadDocument() {
const editor = runtime.getEditor()
const documentId = await resolveChatDocumentId(editor, store)
if (isIdentityChange(editor, documentId)) await reassignDocument(editor, documentId)
if (current.value?.documentId === documentId) {
readOnly.value = false
owner = editor
ownerRecoveryId = editor.getRecoveryId()
return
}
await detach()
const id = await store.getSelected(documentId)
const conversation = id ? await store.read(id) : null
if (conversation?.documentId === documentId) await activate(conversation)
else await createDraft()
await refresh()
}
function ensureChat() {
return serialize(async () => {
if (readOnly.value && owner === runtime.getEditor()) return null
await loadDocument()
// A restored transcript does not restore an external agent session.
if (
current.value?.messages.length &&
!session.chat &&
(runtime.backend() !== 'direct' || current.value.backend !== 'direct')
)
return null
const editor = runtime.getEditor()
const next = await runtime.ensureChat(messages.value, current.value?.id)
if (runtime.getEditor() !== editor) {
await next?.stop()
await runtime.resetChat()
return null
}
if (next) session.attach(next, flush)
return next
})
}
function initialize() {
return serialize(loadDocument)
}
function newChat() {
return serialize(async () => {
await detach()
await createDraft()
})
}
function open(id: string) {
return serialize(async () => {
if (current.value?.id === id) return
await detach()
const conversation = await store.read(id)
if (conversation) await activate(conversation)
})
}
function rename(id: string, title: string) {
return serialize(async () => {
if (current.value?.id === id && current.value.messages.length === 0 && title.trim()) {
current.value = { ...current.value, title: title.trim(), titleSource: 'manual' }
await flush()
}
await store.rename(id, title)
if (current.value?.id === id && title.trim())
current.value = { ...current.value, title: title.trim(), titleSource: 'manual' }
await refresh()
})
}
function remove(id: string) {
return serialize(async () => {
if (current.value?.id === id) {
await detach()
current.value = null
messages.value = []
}
await store.remove(id)
if (!current.value) await createDraft()
await refresh()
})
}
return {
current,
messages,
conversations,
readOnly,
busy,
storageError,
initialize,
ensureChat,
flush,
newChat,
open,
rename,
remove
}
}

View file

@ -0,0 +1,37 @@
import type { EditorStore } from '@/app/editor/active-store'
import type { ConversationStore } from './types'
export type ChatDocumentEditor = Pick<
EditorStore,
'getSourceIdentity' | 'getStorageBinding' | 'getDocumentFilePath' | 'getRecoveryId'
> & { state: { documentName: string } }
const fileIds = new WeakMap<FileSystemFileHandle, string>()
export async function resolveChatDocumentId(
editor: ChatDocumentEditor,
store: ConversationStore
): Promise<string> {
const handle = editor.getSourceIdentity().handle
if (handle && !editor.getStorageBinding() && !editor.getDocumentFilePath()) {
const id = fileIds.get(handle) ?? (await store.resolveFile(handle))
fileIds.set(handle, id)
return id
}
return chatDocumentId(editor)
}
/** Runtime tab IDs are not document identities. Recovery IDs survive untitled recovery. */
export function chatDocumentId(editor: ChatDocumentEditor): string {
const binding = editor.getStorageBinding()
if (binding) return `storage:${JSON.stringify([binding.providerId, binding.documentId])}`
const path = editor.getDocumentFilePath()
if (path) return `file:${path}`
const handle = editor.getSourceIdentity().handle
if (handle) {
const id = fileIds.get(handle)
if (id) return id
}
return `recovery:${editor.getRecoveryId()}`
}

View file

@ -0,0 +1,154 @@
import type { DBSchema } from 'idb'
import { APP_DATABASE_NAMES, defineAppDatabase, openAppDatabase } from '@/app/storage/idb'
import type { Conversation, ConversationMeta, ConversationStore } from './types'
interface ChatDatabase extends DBSchema {
conversations: { key: string; value: ConversationMeta; indexes: { document: string } }
messages: { key: string; value: Conversation['messages'] }
files: { key: string; value: FileSystemFileHandle }
deleted: { key: string; value: true }
selected: { key: string; value: string }
}
const definition = defineAppDatabase<ChatDatabase>({
name: APP_DATABASE_NAMES.chats,
version: 3,
callbacks: {
upgrade(database, oldVersion) {
if (oldVersion < 1) {
database
.createObjectStore('conversations', { keyPath: 'id' })
.createIndex('document', 'documentId')
database.createObjectStore('messages')
database.createObjectStore('selected')
}
if (oldVersion < 2) database.createObjectStore('deleted')
if (oldVersion < 3) database.createObjectStore('files')
}
}
})
/** Transcript snapshots and Blob previews are committed atomically with their list metadata. */
export function createConversationStore(): ConversationStore {
let database: ReturnType<typeof openAppDatabase<ChatDatabase>> | undefined
const getDatabase = () => (database ??= openAppDatabase(definition))
return {
async list(documentId) {
const db = await getDatabase()
const rows =
documentId === undefined
? await db.getAll('conversations')
: await db.getAllFromIndex('conversations', 'document', documentId)
return rows.toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt))
},
async read(id) {
const transaction = (await getDatabase()).transaction(['conversations', 'messages'])
const meta = await transaction.objectStore('conversations').get(id)
const messages = await transaction.objectStore('messages').get(id)
await transaction.done
return meta ? { ...meta, messages: messages ?? [] } : null
},
async write(conversation) {
const { messages, ...meta } = conversation
const transaction = (await getDatabase()).transaction(
['conversations', 'messages', 'deleted'],
'readwrite'
)
if (await transaction.objectStore('deleted').get(meta.id)) {
await transaction.done
throw new Error('Conversation was deleted')
}
const existing = await transaction.objectStore('conversations').get(meta.id)
if (existing) {
meta.documentId = existing.documentId
meta.documentName = existing.documentName
}
// A delayed streaming snapshot must not undo a manual rename.
if (existing?.titleSource === 'manual') {
meta.title = existing.title
meta.titleSource = 'manual'
}
await transaction.objectStore('conversations').put(meta)
await transaction.objectStore('messages').put(messages, meta.id)
await transaction.done
},
async rename(id, title) {
const trimmed = title.trim()
if (!trimmed) return
const transaction = (await getDatabase()).transaction('conversations', 'readwrite')
const meta = await transaction.store.get(id)
if (meta) await transaction.store.put({ ...meta, title: trimmed, titleSource: 'manual' })
await transaction.done
},
async remove(id) {
const transaction = (await getDatabase()).transaction(
['conversations', 'messages', 'selected', 'deleted'],
'readwrite'
)
await transaction.objectStore('deleted').put(true, id)
await transaction.objectStore('conversations').delete(id)
await transaction.objectStore('messages').delete(id)
let cursor = await transaction.objectStore('selected').openCursor()
while (cursor) {
if (cursor.value === id) await cursor.delete()
cursor = await cursor.continue()
}
await transaction.done
},
async resolveFile(handle) {
return navigator.locks.request('open-pencil-chat-file-identity', async () => {
const db = await getDatabase()
// isSameEntry is asynchronous and must run outside an IDB transaction.
const transaction = db.transaction('files')
const keys = await transaction.store.getAllKeys()
const handles = await transaction.store.getAll()
await transaction.done
for (const [index, saved] of handles.entries()) {
try {
if (await saved.isSameEntry(handle)) return String(keys[index])
} catch {
console.warn('[Chat history] Could not compare a saved file handle')
}
}
const id = `browser-file:${crypto.randomUUID()}`
await db.put('files', handle, id)
return id
})
},
async reassignDocument(from, to, name) {
const transaction = (await getDatabase()).transaction(
['conversations', 'selected'],
'readwrite'
)
const rows = await transaction.objectStore('conversations').index('document').getAll(from)
for (const row of rows) {
await transaction
.objectStore('conversations')
.put({ ...row, documentId: to, documentName: name })
}
const selected = await transaction.objectStore('selected').get(from)
if (selected) await transaction.objectStore('selected').put(selected, to)
if (from !== to) await transaction.objectStore('selected').delete(from)
await transaction.done
},
async getSelected(documentId) {
return (await (await getDatabase()).get('selected', documentId)) ?? null
},
async select(documentId, id) {
const transaction = (await getDatabase()).transaction(
['conversations', 'selected'],
'readwrite'
)
const meta = await transaction.objectStore('conversations').get(id)
if (!meta || meta.documentId !== documentId) {
transaction.abort()
await transaction.done.catch(() => undefined)
throw new Error('Conversation belongs to a different document')
}
await transaction.objectStore('selected').put(id, documentId)
await transaction.done
}
}
}

View file

@ -0,0 +1,44 @@
import type { UIMessage } from 'ai'
import { toRaw } from 'vue'
import {
attachmentsForMessage,
setMessageAttachments
} from '@/app/ai/attachment/presentation/store'
import { visibleMessageText, setVisibleMessageText } from '@/app/ai/chat/presentation'
import type { ConversationMessage } from './types'
export function snapshotMessages(messages: UIMessage[]): ConversationMessage[] {
return messages.map((message) => {
const text = message.parts
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('')
return {
message: structuredClone(toRaw(message)),
displayText: visibleMessageText(message.id, text),
attachments: attachmentsForMessage(message.id).value.map((attachment) =>
structuredClone(toRaw(attachment))
)
}
})
}
export function restoreMessages(messages: ConversationMessage[]): UIMessage[] {
for (const row of messages) {
if (row.displayText !== undefined) setVisibleMessageText(row.message.id, row.displayText)
setMessageAttachments(row.message.id, row.attachments)
}
return messages.map((row) => structuredClone(row.message))
}
export function fallbackTitle(messages: UIMessage[]): string {
const first = messages.find((message) => message.role === 'user')
if (!first) return ''
const text = first.parts
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join(' ')
return visibleMessageText(first.id, text).replace(/\s+/g, ' ').trim().slice(0, 120)
}

View file

@ -0,0 +1,55 @@
import type { UIMessage } from 'ai'
import { ref, type Ref } from 'vue'
import { fallbackTitle, snapshotMessages } from './messages'
import type { Conversation, ConversationStore } from './types'
interface PersistenceOptions {
store: ConversationStore
current: Ref<Conversation | null>
messages: Ref<UIMessage[]>
readOnly: Ref<boolean>
content(): { messages: UIMessage[]; interrupted: boolean }
refresh(): Promise<void>
}
/** Captures snapshots before queuing writes; failures remain visible and retryable. */
export function createHistoryPersistence(options: PersistenceOptions) {
const storageError = ref(false)
let writes: Promise<void> = Promise.resolve()
function flush(): Promise<void> {
const conversation = options.current.value
if (!conversation || options.readOnly.value) return writes
const content = options.content()
if (!content.messages.length && conversation.titleSource === 'fallback') return writes
const snapshot: Conversation = {
...conversation,
title:
conversation.titleSource === 'fallback'
? fallbackTitle(content.messages)
: conversation.title,
updatedAt: new Date().toISOString(),
interrupted: content.interrupted,
messages: snapshotMessages(content.messages)
}
options.current.value = snapshot
options.messages.value = content.messages
writes = writes
.catch(() => undefined)
.then(async () => {
await options.store.write(snapshot)
await options.store.select(snapshot.documentId, snapshot.id)
storageError.value = false
await options.refresh()
return undefined
})
.catch((error) => {
storageError.value = true
throw error
})
return writes
}
return { flush, storageError }
}

View file

@ -0,0 +1,63 @@
import { watchThrottled } from '@vueuse/core'
import { watch } from 'vue'
import type { HistoryChat } from './types'
/** Owns live subscriptions; a failed final save deliberately leaves the session attached. */
export function createHistorySession(reset: () => Promise<void>) {
let live: HistoryChat | null = null
let interrupted = false
let generation = 0
let stopWatch: (() => void) | undefined
function attach(next: HistoryChat, flush: () => Promise<void>): void {
if (live === next) return
stopWatch?.()
live = next
const activeGeneration = ++generation
function save() {
if (live === next && generation === activeGeneration) void flush().catch(() => undefined)
}
const stopMessages = watchThrottled(() => next.messages, save, {
deep: true,
throttle: 500,
trailing: true
})
const stopStatus = watch(
() => next.status,
() => {
if (next.status === 'submitted') interrupted = false
save()
}
)
stopWatch = () => {
stopMessages()
stopStatus()
}
}
async function detach(flush: () => Promise<void>): Promise<void> {
interrupted ||= live?.status === 'submitted' || live?.status === 'streaming'
await live?.stop()
await flush()
generation++
stopWatch?.()
stopWatch = undefined
await reset()
live = null
}
return {
attach,
detach,
get chat() {
return live
},
get interrupted() {
return interrupted || live?.status === 'submitted' || live?.status === 'streaming'
},
restoreInterrupted(value: boolean) {
interrupted = value
}
}
}

View file

@ -0,0 +1,51 @@
import type { Chat } from '@ai-sdk/vue'
import type { UIMessage } from 'ai'
import type { AttachmentPresentation } from '@/app/ai/attachment/presentation/types'
import type { ChatDocumentEditor } from './document'
export type HistoryChat = Pick<Chat<UIMessage>, 'messages' | 'status' | 'stop'>
export interface HistoryRuntime<TChat extends HistoryChat> {
getEditor(): ChatDocumentEditor
ensureChat(messages?: UIMessage[], sessionId?: string): Promise<TChat | null>
resetChat(): Promise<void>
backend(): ConversationMeta['backend']
profileId(): string | null
}
export interface ConversationMeta {
id: string
documentId: string
documentName: string
title: string
titleSource: 'fallback' | 'manual' | 'generated'
createdAt: string
updatedAt: string
profileId: string | null
backend: 'direct' | 'acp' | 'harness'
interrupted: boolean
}
export interface ConversationMessage {
message: UIMessage
displayText?: string
attachments: AttachmentPresentation[]
}
export interface Conversation extends ConversationMeta {
messages: ConversationMessage[]
}
export interface ConversationStore {
list(documentId?: string): Promise<ConversationMeta[]>
read(id: string): Promise<Conversation | null>
write(conversation: Conversation): Promise<void>
rename(id: string, title: string): Promise<void>
remove(id: string): Promise<void>
resolveFile(handle: FileSystemFileHandle): Promise<string>
reassignDocument(from: string, to: string, name: string): Promise<void>
getSelected(documentId: string): Promise<string | null>
select(documentId: string, id: string): Promise<void>
}

View file

@ -0,0 +1,10 @@
import { computed } from 'vue'
import { appPreferences, type ReasoningDisplay } from '@/app/settings/preferences/store'
export const reasoningDisplay = computed({
get: () => appPreferences.value.chat.reasoningDisplay,
set: (reasoningDisplay: ReasoningDisplay) => {
appPreferences.value = { ...appPreferences.value, chat: { reasoningDisplay } }
}
})

View file

@ -29,6 +29,7 @@ interface SubmissionMessages {
interface SubmissionOptions {
chat: Ref<Chat<UIMessage> | null>
ensureChat: () => Promise<Chat<UIMessage> | null>
flush?: () => Promise<void>
clearFailure: () => void
getEditor: () => EditorStore
messages: Ref<SubmissionMessages>
@ -123,8 +124,8 @@ export function useChatSubmission(options: SubmissionOptions) {
isPreparingAttachments.value = submission.images.length > 0
options.clearFailure()
try {
const currentChat = options.chat.value ?? (await options.ensureChat())
if (currentChat) options.chat.value = markRaw(currentChat)
const currentChat = await options.ensureChat()
if (currentChat && version === operationVersion) options.chat.value = markRaw(currentChat)
if (!currentChat || version !== operationVersion) {
for (const image of submission.images) revokeImagePreviewURL(image.previewURL)
if (submission.images.length > 0) options.reportError(options.messages.value.requestFailed)
@ -138,6 +139,7 @@ export function useChatSubmission(options: SubmissionOptions) {
} catch (error) {
reportSubmissionError(error)
} finally {
await options.flush?.().catch(() => undefined)
if (version === operationVersion) isPreparingAttachments.value = false
}
}

View file

@ -20,6 +20,8 @@ import {
} from '@/app/diagnostics/events'
import type { getActiveEditorStore } from '@/app/editor/active-store'
import { resumableTransport } from './history/continuation'
type EditorStore = ReturnType<typeof getActiveEditorStore>
type ChatSessionOptions = {
@ -117,13 +119,15 @@ export function createToolLoopTransport({
}
})
return new DirectChatTransport({
agent,
onError: (error) => {
onError?.(error)
return 'The provider rejected the request.'
}
}) as ChatTransport<UIMessage>
return resumableTransport(
new DirectChatTransport({
agent,
onError: (error) => {
onError?.(error)
return 'The provider rejected the request.'
}
}) as ChatTransport<UIMessage>
)
}
export function createChatSessionManager({
@ -137,10 +141,10 @@ export function createChatSessionManager({
const failure = ref<AIChatFailure | null>(null)
let transportDirty = false
let currentChatStore: EditorStore | null = null
let currentChatMessages = new WeakMap<EditorStore, UIMessage[]>()
const currentChatMessages = new WeakMap<EditorStore, UIMessage[]>()
let chat: Chat<UIMessage> | null = null
let acpTransportInstance: { destroy(): Promise<void> } | null = null
let harnessTransportInstance: { destroy(): Promise<void> } | null = null
let harnessTransportInstance: { stop(): Promise<void> } | null = null
let overrideTransport: (() => ChatTransport<UIMessage>) | null = null
let activeProviderError: unknown = null
@ -171,8 +175,6 @@ export function createChatSessionManager({
function markTransportDirty() {
transportDirty = true
currentChatStore = null
currentChatMessages = new WeakMap()
}
async function destroyAgentTransports(): Promise<void> {
@ -180,7 +182,7 @@ export function createChatSessionManager({
const harness = harnessTransportInstance
acpTransportInstance = null
harnessTransportInstance = null
const results = await Promise.allSettled([acp?.destroy(), harness?.destroy()])
const results = await Promise.allSettled([acp?.destroy(), harness?.stop()])
const errors = results
.filter((result) => result.status === 'rejected')
.map((result) => result.reason)
@ -194,18 +196,19 @@ export function createChatSessionManager({
return transport as ChatTransport<UIMessage>
}
async function createActiveHarnessTransport() {
async function createActiveHarnessTransport(sessionId: string) {
await destroyAgentTransports()
const runtime = await createAIModelRuntime('design')
if (runtime?.kind !== 'harness') throw new Error('The Design agent is not configured for Pi')
const [{ HarnessChatTransport }, { buildPiMCPServers }, { getActiveTabId }] = await Promise.all(
[import('@/app/ai/harness/transport'), import('@/app/integrations/mcp'), import('@/app/tabs')]
)
const [{ HarnessChatTransport }, { buildPiMCPServers }] = await Promise.all([
import('@/app/ai/harness/transport'),
import('@/app/integrations/mcp')
])
const apiKey = await resolveModelConnectionAPIKey(runtime.role.connection.id)
if (!apiKey) throw new Error('Credential is unavailable for the Pi agent')
const model = runtime.role.profile.customModelID || runtime.role.profile.modelID
const transport = new HarnessChatTransport(
`tab-${getActiveTabId()}-${runtime.role.profile.id}`,
sessionId,
{
adapter: 'pi',
sandbox: 'just-bash',
@ -247,7 +250,10 @@ export function createChatSessionManager({
})
}
async function ensureChat(): Promise<Chat<UIMessage> | null> {
async function ensureChat(
initialMessages?: UIMessage[],
sessionId = crypto.randomUUID()
): Promise<Chat<UIMessage> | null> {
await credentialsReady
if (!isConfigured.value) return null
@ -257,10 +263,10 @@ export function createChatSessionManager({
}
if (!chat || transportDirty || currentChatStore !== store) {
const messages = currentChatMessages.get(store)
const messages = initialMessages ?? currentChatMessages.get(store)
let transport: ChatTransport<UIMessage>
if (isACPProvider.value) transport = await createActiveACPTransport()
else if (isHarnessProvider.value) transport = await createActiveHarnessTransport()
else if (isHarnessProvider.value) transport = await createActiveHarnessTransport(sessionId)
else transport = await createTransport(store)
chat = new Chat<UIMessage>({
transport,

View file

@ -2,6 +2,7 @@ import { ref } from 'vue'
import { IS_BROWSER } from '@open-pencil/core/constants'
import { createConversationHistory } from '@/app/ai/chat/history/controller'
import {
apiKeyStatus,
browserCredentialsRemembered,
@ -21,6 +22,7 @@ import {
setAPIKey
} from '@/app/ai/chat/storage'
import { createChatSessionManager } from '@/app/ai/chat/transports'
import { designModelProfile } from '@/app/ai/models'
import { exposeChatTransportOverride } from '@/app/browser-bridge'
import { getActiveEditorStore } from '@/app/editor/active-store'
import {
@ -42,6 +44,17 @@ const chatSession = createChatSessionManager({
getActiveEditorStore
})
const history = createConversationHistory({
profileId: () => designModelProfile.value?.id ?? null,
getEditor: getActiveEditorStore,
ensureChat: chatSession.ensureChat,
resetChat: chatSession.resetChat,
backend: () => {
if (isACPProvider.value) return 'acp'
return isHarnessProvider.value ? 'harness' : 'direct'
}
})
registerAIChatEffects(chatSession.markTransportDirty)
if (IS_BROWSER) {
@ -70,8 +83,9 @@ export function useAIChat() {
setUnsplashKey,
activeTab,
isConfigured,
ensureChat: chatSession.ensureChat,
resetChat: chatSession.resetChat,
history,
ensureChat: history.ensureChat,
resetChat: history.newChat,
chatFailure: chatSession.failure,
clearChatFailure: chatSession.clearFailure
}

View file

@ -70,7 +70,8 @@ export class HarnessChatTransport implements ChatTransport<UIMessage> {
constructor(
private readonly sessionId: string,
private readonly configuration: HarnessSessionConfiguration,
private readonly environment: Record<string, string>
private readonly environment: Record<string, string>,
private readonly spawnProcess: typeof spawnHarnessProcess = spawnHarnessProcess
) {}
async sendMessages({
@ -135,6 +136,19 @@ export class HarnessChatTransport implements ChatTransport<UIMessage> {
return null
}
async stop(): Promise<void> {
if (this.destroyed) return
try {
if (this.sessionCreated) {
await this.request({ method: 'session.stop', params: { sessionId: this.sessionId } })
}
} finally {
// Stopping must never delete resume state, including when saving it fails.
this.sessionCreated = false
await this.destroy()
}
}
async destroy(): Promise<void> {
if (this.destroyed) return
this.destroyed = true
@ -190,7 +204,7 @@ export class HarnessChatTransport implements ChatTransport<UIMessage> {
if (this.process) return this.process
this.destroyed = false
const environment = this.environment
const process = await spawnHarnessProcess({
const process = await this.spawnProcess({
environment,
onUnexpectedClose: () => this.failAll(new Error('Harness process exited unexpectedly'))
})

View file

@ -29,7 +29,7 @@ export function devAutomationRoute(
const suffix = appURL.hostname.slice(markerIndex + APP_NAME.length)
const mcpHostname = `${prefix}${MCP_SERVICE_NAME}${suffix}`
return {
browserURL: `wss://${mcpHostname}`,
browserURL: `wss://${mcpHostname}${appURL.port ? `:${appURL.port}` : ''}`,
corsOrigin: appURL.origin,
portlessServiceName: MCP_SERVICE_NAME,
runtimeId: mcpHostname

View file

@ -2,9 +2,12 @@ import { useLocalStorage } from '@vueuse/core'
import { DEFAULT_SNAPPING_PREFERENCES, type SnappingPreferences } from '@open-pencil/core/editor'
export type ReasoningDisplay = 'collapsed' | 'while-thinking' | 'expanded'
export type CanvasRenderingMode = 'retained' | 'tiled'
export interface AppPreferences {
chat: { reasoningDisplay: ReasoningDisplay }
version: 1
recovery: {
enabled: boolean
@ -18,6 +21,7 @@ export interface AppPreferences {
}
export const DEFAULT_APP_PREFERENCES: Readonly<AppPreferences> = {
chat: { reasoningDisplay: 'collapsed' },
version: 1,
recovery: { enabled: true },
editing: {
@ -39,6 +43,7 @@ interface StoredSnappingPreferences {
}
interface StoredAppPreferences {
chat?: { reasoningDisplay?: unknown }
recovery?: { enabled?: unknown }
editing?: { snapping?: StoredSnappingPreferences }
rendering?: { canvasMode?: unknown }
@ -53,6 +58,13 @@ function normalizePreferences(value: unknown): AppPreferences {
const snapping = stored?.editing?.snapping
return {
chat: {
reasoningDisplay:
stored?.chat?.reasoningDisplay === 'expanded' ||
stored?.chat?.reasoningDisplay === 'while-thinking'
? stored.chat.reasoningDisplay
: 'collapsed'
},
version: 1,
recovery: {
enabled: booleanOrDefault(stored?.recovery?.enabled, DEFAULT_APP_PREFERENCES.recovery.enabled)

View file

@ -1,5 +1,6 @@
/** Stable names for the app's independent IndexedDB databases. */
export const APP_DATABASE_NAMES = {
chats: 'open-pencil-chats',
credentials: 'open-pencil-credentials',
libraries: 'open-pencil-libraries',
localCanvas: 'open-pencil-cloud-local',

View file

@ -1,21 +1,18 @@
<script setup lang="ts">
import { ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
import { refAutoReset, useClipboard } from '@vueuse/core'
import { computed, markRaw, shallowRef, nextTick, ref, watch } from 'vue'
import { useClipboard } from '@vueuse/core'
import { computed, markRaw, ref, shallowRef, watch } from 'vue'
import { getACPDebugText, clearACPDebugLog, hasACPDebugEntries } from '@/app/ai/acp/transport'
import { getACPDebugText, hasACPDebugEntries } from '@/app/ai/acp/transport'
import { copyChatLog } from '@/app/ai/debug'
import { clearVisibleMessageText } from '@/app/ai/chat/presentation'
import { useChatSubmission } from '@/app/ai/chat/submission/use'
import { clearMessageAttachments } from '@/app/ai/attachment/presentation/store'
import { clearToolLogEntries, didHitStepLimit } from '@/app/ai/tools'
import { didHitStepLimit } from '@/app/ai/tools'
import { activeTab } from '@/app/tabs'
import { getActiveEditorStore } from '@/app/editor/active-store'
import ACPPermissionDialog from '@/components/chat/ACPPermissionDialog.vue'
import ChatInput from '@/components/chat/ChatInput.vue'
import ChatMessage from '@/components/chat/ChatMessage.vue'
import AppPlaceholder from '@/components/ui/feedback/AppPlaceholder.vue'
import AppButton from '@/components/ui/button/AppButton.vue'
import ChatHistory from '@/components/chat/ChatHistory.vue'
import { chatDocumentId } from '@/app/ai/chat/history/document'
import ChatTranscript from '@/components/chat/ChatTranscript.vue'
import ProviderSetup from '@/components/chat/ProviderSetup.vue'
import { useAIChat } from '@/app/ai/chat/use'
import { toast } from '@/app/shell/ui'
@ -26,11 +23,10 @@ import { useNotificationMessages } from '@/app/i18n/notifications'
import type { Chat } from '@ai-sdk/vue'
import type { UIMessage } from 'ai'
import type { JSONObject } from '@open-pencil/scene-graph/primitives'
const IS_DEV = import.meta.env.DEV
const { isConfigured, ensureChat, resetChat, chatFailure, clearChatFailure } = useAIChat()
const { isConfigured, ensureChat, history, chatFailure, clearChatFailure } = useAIChat()
const { copy } = useClipboard()
const { ai } = useI18n()
const notifications = useNotificationMessages()
@ -39,6 +35,7 @@ const chat = shallowRef<Chat<UIMessage> | null>(null)
const submission = useChatSubmission({
chat,
ensureChat,
flush: history.flush,
clearFailure: clearChatFailure,
getEditor: getActiveEditorStore,
messages: computed(() => ({
@ -50,9 +47,11 @@ const submission = useChatSubmission({
openModelSettings: () => openSettingsDialog('ai')
})
let viewGeneration = 0
const initialGeneration = ++viewGeneration
void ensureChat()
.then((c) => {
if (c) chat.value = markRaw(c)
if (c && initialGeneration === viewGeneration) chat.value = markRaw(c)
return undefined
})
.catch((error: unknown) => {
@ -62,11 +61,43 @@ void ensureChat()
})
)
})
const messagesEnd = ref<HTMLDivElement>()
const debugCopied = refAutoReset(false, 1500)
const acpLogCopied = refAutoReset(false, 1500)
const messages = computed(() => chat.value?.messages ?? [])
const messages = computed(() => chat.value?.messages ?? history.messages.value)
const historyOptions = computed(() => {
const current = history.current.value
const rows = [...history.conversations.value]
if (current && !rows.some((row) => row.id === current.id)) rows.unshift(current)
return rows.map((conversation) => ({
...conversation,
available: conversation.documentId === chatDocumentId(getActiveEditorStore())
}))
})
const agentHistoryReadOnly = computed(
() => !chat.value && messages.value.length > 0 && history.current.value?.backend !== 'direct'
)
async function historyAction(action: () => Promise<unknown>) {
const generation = ++viewGeneration
submission.cancel()
try {
await chat.value?.stop()
await action()
if (generation !== viewGeneration) return
chat.value = null
const next = await ensureChat()
if (generation === viewGeneration) chat.value = next ? markRaw(next) : null
} catch {
toast.error(ai.value.chatHistoryFailed)
}
}
async function renameConversation(id: string, title: string) {
try {
await history.rename(id, title)
} catch {
toast.error(ai.value.chatHistoryFailed)
}
}
const failureMessage = computed(() => {
switch (chatFailure.value?.reason) {
case 'authentication':
@ -93,42 +124,14 @@ const failureHasSettingsAction = computed(() =>
['authentication', 'forbidden', 'model-not-found'].includes(chatFailure.value?.reason ?? '')
)
const status = computed(() => chat.value?.status ?? 'ready')
function isStreamingMessage(message: UIMessage, index: number): boolean {
return (
message.role === 'assistant' &&
index === messages.value.length - 1 &&
(status.value === 'submitted' || status.value === 'streaming')
)
}
const isThinking = computed(() => {
const s = status.value
if (s !== 'submitted' && s !== 'streaming') return false
if (messages.value.length === 0) return true
const last = messages.value[messages.value.length - 1]
if (last.role !== 'assistant') return true
const parts = last.parts
if (parts.length === 0) return true
const lastPart = parts[parts.length - 1] as JSONObject
if (lastPart.type === 'step-start') return true
if ('toolCallId' in lastPart && lastPart.state === 'output-available') return true
if ('toolCallId' in lastPart && lastPart.state === 'output-error') return true
return s === 'submitted'
})
const showContinue = computed(() => {
if (history.readOnly.value || agentHistoryReadOnly.value) return false
if (status.value !== 'ready') return false
if (messages.value.length === 0) return false
const last = messages.value[messages.value.length - 1]
return last.role === 'assistant' && didHitStepLimit()
})
function scrollToBottom() {
nextTick(() => {
messagesEnd.value?.scrollIntoView({ behavior: 'smooth', block: 'end' })
})
}
watch(messages, scrollToBottom, { deep: true })
watch(
() => chatFailure.value?.reason,
(reason) => {
@ -145,13 +148,24 @@ watch(
}
)
watch(
() => activeTab.value?.id,
async () => {
() => [activeTab.value?.id, activeTab.value?.store.state.preparation] as const,
async ([, preparation]) => {
if (preparation) {
viewGeneration++
submission.cancel()
return
}
const generation = ++viewGeneration
submission.cancel()
clearMessageAttachments()
clearVisibleMessageText()
const nextChat = await ensureChat()
chat.value = nextChat ? markRaw(nextChat) : null
chat.value = null
try {
await history.initialize()
if (generation !== viewGeneration) return
const nextChat = await ensureChat()
if (generation === viewGeneration) chat.value = nextChat ? markRaw(nextChat) : null
} catch {
if (generation === viewGeneration) toast.error(ai.value.chatHistoryFailed)
}
}
)
@ -159,138 +173,83 @@ function handleStop() {
submission.stop()
}
const diagnosticNotice = ref('')
async function copyDiagnostics(operation: () => Promise<void>) {
diagnosticNotice.value = ''
try {
await operation()
diagnosticNotice.value = ai.value.diagnosticCopied
} catch {
diagnosticNotice.value = ai.value.diagnosticCopyFailed
}
}
async function handleCopyDebug() {
await copyChatLog(messages.value, chatFailure.value)
debugCopied.value = true
await copyDiagnostics(() => copyChatLog(messages.value, chatFailure.value))
}
async function handleCopyACPLog() {
const text = getACPDebugText()
if (!text) return
await copy(text)
acpLogCopied.value = true
}
function handleClearChat() {
submission.cancel()
clearChatFailure()
clearMessageAttachments()
clearVisibleMessageText()
chat.value = null
void resetChat().catch((error: unknown) => {
console.error('Chat reset error:', error)
})
clearToolLogEntries()
clearACPDebugLog()
await copyDiagnostics(() => copy(text))
}
</script>
<template>
<div data-test-id="chat-panel" class="flex min-w-0 flex-1 flex-col overflow-hidden select-text">
<ChatHistory
:saved="history.conversations.value.some((row) => row.id === history.current.value?.id)"
:debug="true"
:acp-debug="IS_DEV && hasACPDebugEntries()"
@copy-debug="handleCopyDebug"
@copy-a-c-p-debug="handleCopyACPLog"
:conversations="historyOptions"
:selected-id="history.current.value?.id"
:disabled="history.busy.value"
@create="historyAction(history.newChat)"
@select="historyAction(() => history.open($event))"
@rename="renameConversation"
@delete="historyAction(() => history.remove($event))"
/>
<p v-if="diagnosticNotice" role="status" class="px-3 py-2 text-xs text-muted">
{{ diagnosticNotice }}
</p>
<p v-if="history.storageError.value" role="alert" class="px-3 py-2 text-xs text-red-400">
{{ ai.chatStorageFailed }}
</p>
<ProviderSetup v-if="!isConfigured" />
<template v-else>
<ScrollAreaRoot class="min-h-0 flex-1">
<ScrollAreaViewport class="h-full px-3 py-3 [&>div]:h-full">
<AppPlaceholder
v-if="messages.length === 0"
data-test-id="chat-empty-state"
:label="ai.describeCreateOrChange"
:ui="{ root: 'h-full' }"
>
<template #icon>
<icon-lucide-message-circle class="size-5" />
</template>
</AppPlaceholder>
<!-- Messages -->
<div v-else data-test-id="chat-messages" class="flex flex-col gap-3">
<ChatMessage
v-for="(msg, index) in messages"
:key="msg.id"
:message="msg"
:streaming="isStreamingMessage(msg, index)"
/>
<!-- Thinking indicator: shown when AI is working but no visible activity -->
<div v-if="isThinking" data-test-id="chat-typing-indicator" class="flex gap-2">
<div
class="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted/20 text-[10px] font-bold text-muted"
>
AI
</div>
<div class="flex items-center gap-1 py-2">
<span
class="size-1.5 animate-bounce rounded-full bg-muted"
style="animation-delay: 0ms"
/>
<span
class="size-1.5 animate-bounce rounded-full bg-muted"
style="animation-delay: 150ms"
/>
<span
class="size-1.5 animate-bounce rounded-full bg-muted"
style="animation-delay: 300ms"
/>
</div>
</div>
<!-- Continue button when step limit reached -->
<div v-if="showContinue" class="flex justify-center py-2">
<button
class="flex items-center gap-1.5 rounded-full bg-accent/10 px-4 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/20"
@click="
submission.submit({
modelText: 'Continue where you left off',
displayText: 'Continue where you left off',
images: [],
nodes: []
})
"
>
<icon-lucide-play class="size-3" />
Continue
</button>
</div>
<div ref="messagesEnd" />
</div>
</ScrollAreaViewport>
<ScrollAreaScrollbar orientation="vertical" class="flex w-1.5 touch-none p-px select-none">
<ScrollAreaThumb class="relative flex-1 rounded-full bg-muted/30" />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
<!-- Chat toolbar -->
<div
v-if="messages.length > 0"
class="flex shrink-0 items-center gap-1 border-t border-border px-3 py-1"
<template v-if="isConfigured || messages.length">
<p
v-if="history.current.value?.interrupted && status === 'ready'"
role="status"
class="px-3 py-2 text-xs text-muted"
>
<AppButton v-if="IS_DEV" color="neutral" variant="ghost" size="xs" @click="handleCopyDebug">
<icon-lucide-clipboard-copy v-if="!debugCopied" class="size-3" />
<icon-lucide-check v-else class="size-3 text-green-400" />
{{ debugCopied ? 'Copied' : 'Copy log' }}
</AppButton>
<AppButton
v-if="IS_DEV && hasACPDebugEntries()"
color="neutral"
variant="ghost"
size="xs"
@click="handleCopyACPLog"
>
<icon-lucide-bug v-if="!acpLogCopied" class="size-3" />
<icon-lucide-check v-else class="size-3 text-green-400" />
{{ acpLogCopied ? 'Copied' : 'ACP log' }}
</AppButton>
<AppButton color="error" variant="ghost" size="xs" @click="handleClearChat">
<icon-lucide-trash-2 class="size-3" />
Clear
</AppButton>
</div>
<ChatInput
{{ ai.chatInterrupted }}
</p>
<ChatTranscript
:messages="messages"
:status="status"
:disabled="submission.busy.value"
:show-continue="showContinue"
@continue="
submission.submit({
modelText: 'Continue where you left off',
displayText: 'Continue where you left off',
images: [],
nodes: []
})
"
/>
<p v-if="agentHistoryReadOnly" role="status" class="px-3 py-2 text-xs text-muted">
{{ ai.chatAgentReadOnly }}
</p>
<p v-if="history.readOnly.value" role="status" class="px-3 py-2 text-xs text-muted">
{{ ai.chatReadOnly }}
</p>
<ChatInput
v-if="isConfigured && !agentHistoryReadOnly && !history.readOnly.value"
:status="status"
:disabled="submission.busy.value || history.busy.value"
@submit="submission.submit"
@stop="handleStop"
@error="toast.error"

View file

@ -0,0 +1,97 @@
<script setup lang="ts">
import { useTextareaAutosize } from '@vueuse/core'
import { TooltipProvider } from 'reka-ui'
import { computed, ref } from 'vue'
import type { ChatStatus } from 'ai'
import { useI18n } from '@open-pencil/vue'
import IconButton from '@/components/ui/button/IconButton.vue'
import InputGroup from '@/components/ui/input/InputGroup.vue'
const { status, disabled = false } = defineProps<{ status: ChatStatus; disabled?: boolean }>()
const emit = defineEmits<{
submit: [text: string]
stop: []
settings: []
paste: [event: ClipboardEvent]
}>()
const { ai } = useI18n()
const textarea = ref<HTMLTextAreaElement>()
const input = ref('')
const { triggerResize } = useTextareaAutosize({ element: textarea, input, maxHeight: 160 })
const isStreaming = computed(() => disabled || status === 'streaming' || status === 'submitted')
function handleInputKeydown(event: KeyboardEvent) {
if (event.code !== 'Enter' || event.shiftKey || event.isComposing) return
event.preventDefault()
const target = event.currentTarget
if (target instanceof HTMLElement) target.closest('form')?.requestSubmit()
}
function handleSubmit(event: Event) {
event.preventDefault()
if (isStreaming.value) return
const text = input.value.trim()
if (!text) return
emit('submit', text)
input.value = ''
triggerResize()
}
</script>
<template>
<TooltipProvider>
<div class="shrink-0 border-t border-border p-2.5">
<form @submit="handleSubmit" @paste.stop="emit('paste', $event)">
<InputGroup :disabled="isStreaming">
<template v-if="$slots.attachment" #attachment><slot name="attachment" /></template>
<textarea
ref="textarea"
v-model="input"
data-test-id="chat-input"
:placeholder="ai.describeChange"
:disabled="isStreaming"
rows="2"
:aria-label="ai.describeChange"
class="block min-h-12 w-full resize-none overflow-y-auto bg-transparent px-3 pt-2.5 pb-1 text-xs leading-relaxed text-surface outline-none placeholder:text-muted disabled:cursor-not-allowed disabled:opacity-60"
@keydown="handleInputKeydown"
@copy.stop
@cut.stop
/>
<template #leading><slot name="leading" /></template>
<template #model><slot name="model" /></template>
<template #actions>
<IconButton
:label="ai.providerSettings"
size="sm"
data-test-id="provider-settings-trigger"
@click="emit('settings')"
>
<icon-lucide-settings class="size-3.5" />
</IconButton>
<IconButton
v-if="isStreaming"
:label="ai.stopGenerating"
size="sm"
data-test-id="chat-stop-button"
class="border border-border"
@click="emit('stop')"
>
<icon-lucide-square class="size-3" />
</IconButton>
<IconButton
v-else
:label="ai.sendMessage"
size="sm"
type="submit"
data-test-id="chat-send-button"
class="bg-accent text-white hover:bg-accent/90 hover:text-white"
:disabled="!input.trim()"
>
<icon-lucide-send class="size-3.5" />
</IconButton>
</template>
</InputGroup>
</form>
</div>
</TooltipProvider>
</template>

View file

@ -0,0 +1,226 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useFocus } from '@vueuse/core'
import {
PopoverRoot,
PopoverTrigger,
PopoverPortal,
PopoverContent,
DropdownMenuRoot,
DropdownMenuTrigger,
DropdownMenuPortal,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator
} from 'reka-ui'
import { useI18n } from '@open-pencil/vue'
import AppButton from '@/components/ui/button/AppButton.vue'
import Tip from '@/components/ui/overlay/Tip.vue'
import { tv } from 'tailwind-variants'
import iconButtonTheme from '@/theme/button/icon-button'
import IconButton from '@/components/ui/button/IconButton.vue'
import { menuItem, useMenuUI } from '@/components/ui/menu/menu'
import { chatHistoryTheme } from '@/theme/chat/history'
const {
conversations,
selectedId,
disabled = false,
saved = false,
debug = false,
acpDebug = false
} = defineProps<{
conversations: { id: string; title: string; documentName: string; available: boolean }[]
selectedId?: string
disabled?: boolean
saved?: boolean
debug?: boolean
acpDebug?: boolean
}>()
const emit = defineEmits<{
select: [id: string]
create: []
rename: [id: string, title: string]
delete: [id: string]
copyDebug: []
copyACPDebug: []
}>()
const { ai } = useI18n()
const triggerClass = tv(iconButtonTheme)({ size: 'sm' })
const styles = chatHistoryTheme()
const menu = useMenuUI({ content: 'min-w-44' })
const item = menuItem({ justify: 'start' })
const open = ref(false)
const all = ref(false)
const query = ref('')
const editing = ref(false)
const confirming = ref(false)
const title = ref('')
const titleInput = ref<HTMLInputElement>()
const { focused } = useFocus(titleInput)
const selected = computed(() => conversations.find((chat) => chat.id === selectedId))
const results = computed(() =>
conversations.filter(
(chat) =>
(all.value || chat.available) &&
`${chat.title} ${chat.documentName}`
.toLocaleLowerCase()
.includes(query.value.trim().toLocaleLowerCase())
)
)
watch(
() => selectedId,
() => {
editing.value = false
confirming.value = false
}
)
function choose(id: string) {
open.value = false
emit('select', id)
}
async function rename() {
confirming.value = false
title.value = selected.value?.title ?? ''
editing.value = true
await nextTick()
focused.value = true
titleInput.value?.select()
}
function confirmDelete() {
editing.value = false
confirming.value = true
}
function remove() {
if (selectedId) emit('delete', selectedId)
confirming.value = false
}
function save() {
if (selectedId && title.value.trim()) emit('rename', selectedId, title.value)
editing.value = false
}
function closeMenu(event: Event) {
if (editing.value) event.preventDefault()
}
</script>
<template>
<div :class="styles.root()">
<div :class="styles.header()">
<PopoverRoot v-model:open="open">
<PopoverTrigger :class="styles.trigger()" :disabled="disabled" :aria-label="ai.chatHistory">
<span :class="styles.title()">{{ selected?.title || ai.newChat }}</span>
<icon-lucide-chevron-down :class="styles.icon()" />
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
align="start"
:side-offset="6"
:class="styles.content()"
:aria-label="ai.chatHistory"
>
<input
v-model="query"
:aria-label="ai.searchChats"
:placeholder="ai.searchChats"
:class="styles.input()"
/>
<div :class="styles.scope()" role="group" :aria-label="ai.chatScope">
<button
:class="styles.scopeButton()"
:data-active="!all"
:aria-pressed="!all"
@click="all = false"
>
{{ ai.thisDocument }}
</button>
<button
:class="styles.scopeButton()"
:data-active="all"
:aria-pressed="all"
@click="all = true"
>
{{ ai.allDocuments }}
</button>
</div>
<div :class="styles.list()">
<button
v-for="chat in results"
:key="chat.id"
:class="styles.row()"
:data-selected="chat.id === selectedId"
:aria-current="chat.id === selectedId ? 'true' : undefined"
:disabled="disabled"
@click="choose(chat.id)"
>
<span :class="styles.title()"
><span :class="styles.label()">{{ chat.title || ai.newChat }}</span
><span v-if="all" :class="styles.detail()">{{ chat.documentName }}</span></span
>
<icon-lucide-check v-if="chat.id === selectedId" :class="styles.icon()" />
</button>
<p v-if="!results.length" :class="styles.empty()">{{ ai.noChatsFound }}</p>
</div>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
<IconButton :label="ai.newChat" :disabled="disabled" size="sm" @click="emit('create')"
><icon-lucide-plus :class="styles.icon()"
/></IconButton>
<DropdownMenuRoot v-if="saved">
<Tip :label="ai.chatActions">
<DropdownMenuTrigger
:class="triggerClass"
:aria-label="ai.chatActions"
:disabled="disabled"
>
<icon-lucide-ellipsis :class="styles.icon()" />
</DropdownMenuTrigger>
</Tip>
<DropdownMenuPortal>
<DropdownMenuContent
align="end"
:side-offset="6"
:class="menu.content"
@close-auto-focus="closeMenu"
>
<DropdownMenuItem :class="item" @select="rename">{{ ai.renameChat }}</DropdownMenuItem>
<DropdownMenuItem :class="item" @select="confirmDelete">{{
ai.deleteChat
}}</DropdownMenuItem>
<DropdownMenuSeparator v-if="debug" class="my-1 border-t border-border" />
<DropdownMenuItem v-if="debug" :class="item" @select="emit('copyDebug')">{{
ai.copyDiagnosticLog
}}</DropdownMenuItem>
<DropdownMenuItem v-if="acpDebug" :class="item" @select="emit('copyACPDebug')">{{
ai.copyACPLog
}}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
<form
v-if="editing"
:class="styles.form()"
@submit.prevent="save"
@keydown.esc.stop="editing = false"
>
<input ref="titleInput" v-model="title" :aria-label="ai.chatTitle" :class="styles.input()" />
<div :class="styles.actions()">
<AppButton size="xs" @click="editing = false">{{ ai.cancelChatAction }}</AppButton
><AppButton size="xs" type="submit" :disabled="disabled || !title.trim()">{{
ai.saveChatTitle
}}</AppButton>
</div>
</form>
<div v-if="confirming" :class="styles.form()" @keydown.esc.stop="confirming = false">
<p :class="styles.confirmation()">{{ ai.deleteChatConfirmation }}</p>
<div :class="styles.actions()">
<AppButton size="xs" @click="confirming = false">{{ ai.cancelChatAction }}</AppButton
><AppButton size="xs" color="error" :disabled="disabled" @click="remove">{{
ai.deleteChat
}}</AppButton>
</div>
</div>
</div>
</template>

View file

@ -1,7 +1,5 @@
<script setup lang="ts">
import { useTextareaAutosize } from '@vueuse/core'
import { TooltipProvider } from 'reka-ui'
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useI18n, useSelectionState } from '@open-pencil/vue'
@ -9,7 +7,7 @@ import ChatNodePreview from '@/components/chat/ChatNodePreview.vue'
import ChatProfileSelect from '@/components/chat/ChatProfileSelect.vue'
import { useAttachmentDrafts } from '@/components/chat/input/useAttachments'
import IconButton from '@/components/ui/button/IconButton.vue'
import InputGroup from '@/components/ui/input/InputGroup.vue'
import ChatComposer from './ChatComposer.vue'
import { MAX_IMAGE_ATTACHMENTS } from '@/app/ai/attachment/image/types'
import type { ChatSubmission } from '@/app/ai/chat/submission/types'
import { useAIChat } from '@/app/ai/chat/use'
@ -33,9 +31,6 @@ const emit = defineEmits<{
error: [message: string]
}>()
const textarea = ref<HTMLTextAreaElement>()
const input = ref('')
const { triggerResize } = useTextareaAutosize({ element: textarea, input, maxHeight: 160 })
const attachments = useAttachmentDrafts({
editor,
selectedIds,
@ -81,159 +76,96 @@ const selectedModelName = computed(() => {
const selectedProfileName = computed(
() => designModelProfile.value?.name ?? selectedModelName.value
)
function handleInputKeydown(event: KeyboardEvent) {
if (event.code !== 'Enter' || event.shiftKey || event.isComposing) return
event.preventDefault()
const target = event.currentTarget
if (target instanceof HTMLElement) target.closest('form')?.requestSubmit()
}
function handleSubmit(e: Event) {
e.preventDefault()
const text = input.value.trim()
if (!text) return
emit('submit', takeSubmission(text))
input.value = ''
triggerResize()
}
</script>
<template>
<TooltipProvider>
<div class="shrink-0 border-t border-border p-2.5">
<form @submit="handleSubmit" @paste.stop="handlePaste">
<InputGroup :disabled="isStreaming">
<template v-if="images.length || referencedNodes.length" #attachment>
<div class="flex flex-wrap gap-1.5">
<div
v-for="node in referencedNodes"
:key="node.id"
data-slot="chat-context-chip"
class="flex min-w-0 max-w-full items-center gap-2 rounded-lg border border-border bg-canvas p-1.5 shadow-xs"
>
<ChatNodePreview :editor="editor" :node="node" />
<span class="min-w-0 flex-1 truncate text-[10px] text-surface">
{{ node.name || node.type }}
</span>
<IconButton
:label="ai.removeNodeContext"
size="xs"
@click="removeReferencedNode(node.id)"
>
<icon-lucide-x class="size-3" />
</IconButton>
</div>
<div
v-for="(image, index) in images"
:key="image.previewURL"
class="flex min-w-0 max-w-full items-center gap-2 rounded-lg border border-border bg-canvas p-1.5 shadow-xs"
>
<img
:src="image.previewURL"
:alt="image.file.name"
width="40"
height="40"
class="size-10 shrink-0 rounded-md border border-border object-cover"
/>
<span class="min-w-0 flex-1 truncate text-[10px] text-surface">
{{ image.file.name }}
</span>
<IconButton
:label="ai.removeImageAttachment({ name: image.file.name })"
size="xs"
@click="removeImage(index)"
>
<icon-lucide-x class="size-3" />
</IconButton>
</div>
</div>
</template>
<textarea
ref="textarea"
v-model="input"
data-test-id="chat-input"
:placeholder="ai.describeChange"
:disabled="isStreaming"
rows="2"
aria-label="Describe a change"
class="block min-h-12 w-full resize-none overflow-y-auto bg-transparent px-3 pt-2.5 pb-1 text-xs leading-relaxed text-surface outline-none placeholder:text-muted disabled:cursor-not-allowed disabled:opacity-60"
@keydown="handleInputKeydown"
@copy.stop
@cut.stop
<ChatComposer
:status="status"
:disabled="disabled"
@submit="emit('submit', takeSubmission($event))"
@stop="emit('stop')"
@paste="handlePaste"
@settings="openSettingsDialog('ai')"
>
<template v-if="images.length || referencedNodes.length" #attachment>
<div class="flex flex-wrap gap-1.5">
<div
v-for="node in referencedNodes"
:key="node.id"
data-slot="chat-context-chip"
class="flex min-w-0 max-w-full items-center gap-2 rounded-lg border border-border bg-canvas p-1.5 shadow-xs"
>
<ChatNodePreview :editor="editor" :node="node" />
<span class="min-w-0 flex-1 truncate text-[10px] text-surface">
{{ node.name || node.type }}
</span>
<IconButton
:label="ai.removeNodeContext"
size="xs"
@click="removeReferencedNode(node.id)"
>
<icon-lucide-x class="size-3" />
</IconButton>
</div>
<div
v-for="(image, index) in images"
:key="image.previewURL"
class="flex min-w-0 max-w-full items-center gap-2 rounded-lg border border-border bg-canvas p-1.5 shadow-xs"
>
<img
:src="image.previewURL"
:alt="image.file.name"
width="40"
height="40"
class="size-10 shrink-0 rounded-md border border-border object-cover"
/>
<template #leading>
<IconButton
:label="ai.addSelectionContext"
size="sm"
:active="selectionContextActive"
:disabled="isStreaming || !canAddSelection"
data-slot="chat-add-selection-context"
@click="toggleCurrentSelection"
>
<icon-lucide-mouse-pointer-2 class="size-4" />
</IconButton>
<IconButton
:label="ai.attachImages"
size="sm"
:disabled="isStreaming || images.length >= MAX_IMAGE_ATTACHMENTS"
@click="openImageDialog()"
>
<icon-lucide-image-plus class="size-4" />
</IconButton>
<span class="min-w-0 flex-1 truncate text-[10px] text-surface">
{{ image.file.name }}
</span>
<IconButton
:label="ai.removeImageAttachment({ name: image.file.name })"
size="xs"
@click="removeImage(index)"
>
<icon-lucide-x class="size-3" />
</IconButton>
</div>
</div>
</template>
<template #leading>
<IconButton
:label="ai.addSelectionContext"
size="sm"
:active="selectionContextActive"
:disabled="isStreaming || !canAddSelection"
data-slot="chat-add-selection-context"
@click="toggleCurrentSelection"
>
<icon-lucide-mouse-pointer-2 class="size-4" />
</IconButton>
<IconButton
:label="ai.attachImages"
size="sm"
:disabled="isStreaming || images.length >= MAX_IMAGE_ATTACHMENTS"
@click="openImageDialog()"
>
<icon-lucide-image-plus class="size-4" />
</IconButton>
</template>
<template #model>
<div class="flex min-w-0 items-center">
<template v-if="isAgentProvider">
<div class="flex min-w-0 items-center gap-1 px-1.5 text-[10px] text-muted">
<icon-lucide-bot class="size-3 shrink-0" />
<span class="truncate">{{ agentName }}</span>
</div>
</template>
<ChatProfileSelect v-else>
<template #value>
<span class="min-w-0 truncate">{{ selectedProfileName }}</span>
</template>
<template #model>
<div class="flex min-w-0 items-center">
<template v-if="isAgentProvider">
<div class="flex min-w-0 items-center gap-1 px-1.5 text-[10px] text-muted">
<icon-lucide-bot class="size-3 shrink-0" />
<span class="truncate">{{ agentName }}</span>
</div>
</template>
<ChatProfileSelect v-else>
<template #value>
<span class="min-w-0 truncate">{{ selectedProfileName }}</span>
</template>
</ChatProfileSelect>
</div>
</template>
<template #actions>
<IconButton
:label="ai.providerSettings"
size="sm"
data-test-id="provider-settings-trigger"
@click="openSettingsDialog('ai')"
>
<icon-lucide-settings class="size-3.5" />
</IconButton>
<IconButton
v-if="isStreaming"
:label="ai.stopGenerating"
size="sm"
data-test-id="chat-stop-button"
class="border border-border"
@click="emit('stop')"
>
<icon-lucide-square class="size-3" />
</IconButton>
<IconButton
v-else
:label="ai.sendMessage"
size="sm"
type="submit"
data-test-id="chat-send-button"
class="bg-accent text-white hover:bg-accent/90 hover:text-white"
:disabled="!input.trim()"
>
<icon-lucide-send class="size-3.5" />
</IconButton>
</template>
</InputGroup>
</form>
</div>
</TooltipProvider>
</ChatProfileSelect>
</div>
</template>
</ChatComposer>
</template>

View file

@ -5,23 +5,31 @@ import { isReasoningUIPart, isTextUIPart, isToolUIPart, getToolName } from 'ai'
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui'
import { useI18n, vTestId } from '@open-pencil/vue'
import type { AttachmentPresentation } from '@/app/ai/attachment/presentation/types'
import { attachmentsForMessage } from '@/app/ai/attachment/presentation/store'
import { visibleUserMessageText } from '@/app/ai/chat/presentation'
import AttachmentList from '@/components/chat/attachment/AttachmentList.vue'
import ChatMarkdown from '@/components/chat/ChatMarkdown.vue'
import { reasoningDisplay } from '@/app/ai/chat/preferences'
import ReasoningBlock from '@/components/chat/ReasoningBlock.vue'
import IconButton from '@/components/ui/button/IconButton.vue'
import { classifyToolState } from './tool-state'
import type { UIDataTypes, UIMessage, UIMessagePart, UITools } from 'ai'
const { message, streaming = false } = defineProps<{
const {
message,
streaming = false,
presentation
} = defineProps<{
message: UIMessage
streaming?: boolean
presentation?: { text?: string; attachments?: AttachmentPresentation[] }
}>()
const { ai } = useI18n()
const markdownMode = computed(() => (streaming ? 'streaming' : 'static'))
const attachments = attachmentsForMessage(message.id)
const storedAttachments = attachmentsForMessage(message.id)
const attachments = computed(() => presentation?.attachments ?? storedAttachments.value)
const assistantText = computed(() =>
message.parts
.filter(isTextUIPart)
@ -87,6 +95,7 @@ function partKey(part: UIMessagePart<UIDataTypes, UITools>, index: number): stri
<ReasoningBlock
v-if="isReasoningUIPart(part) && part.text"
:text="part.text"
:display="reasoningDisplay"
:streaming="part.state === 'streaming'"
:thinking-label="ai.thinking"
:reasoning-label="ai.reasoning"
@ -175,6 +184,7 @@ function partKey(part: UIMessagePart<UIDataTypes, UITools>, index: number): stri
class="rounded-xl rounded-br-md bg-accent px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap text-white"
>
{{
presentation?.text ??
visibleUserMessageText(
message.id,
message.parts

View file

@ -0,0 +1,120 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { TooltipProvider } from 'reka-ui'
import { expect, userEvent, within } from 'storybook/test'
import AppButton from '@/components/ui/button/AppButton.vue'
import AppSelect from '@/components/ui/select/AppSelect.vue'
import ChatComposer from './ChatComposer.vue'
import ChatHistory from './ChatHistory.vue'
import ChatTranscript from './ChatTranscript.vue'
import { useConversations } from './stories/useConversations'
interface Args {
initialChat: string
narrow: boolean
}
const meta = {
title: 'Chat/Playground',
args: { initialChat: 'dashboard', narrow: false },
parameters: {
docs: {
description: {
component:
'The real transcript and composer, composed with isolated story fixtures. Controls above the panel are test controls, not the proposed history UI. No credentials, model calls, document edits, or persistence.'
}
}
},
render: (args) => ({
components: {
ChatHistory,
ChatComposer,
ChatTranscript,
TooltipProvider,
AppSelect,
AppButton
},
setup() {
return { args, ...useConversations(args.initialChat) }
},
template: `
<TooltipProvider>
<div class="max-w-full space-y-3">
<fieldset class="max-w-[420px] space-y-2 rounded border border-border p-3 text-xs">
<legend class="px-1 text-muted">Story controls — not saved</legend>
<AppSelect v-model="selectedId" label="Test conversation" :options="options" />
<div class="flex gap-2">
<AppButton size="xs" @click="newChat">New fixture</AppButton>
<AppButton size="xs" @click="removeChat">Delete fixture</AppButton>
</div>
<label v-if="selected" class="flex items-center gap-2">Title
<input v-model="selected.title" class="min-w-0 flex-1 rounded border border-border bg-input p-1" />
</label>
</fieldset>
<section aria-label="Chat preview" :data-narrow="args.narrow" class="flex h-[620px] w-[420px] max-w-full flex-col overflow-hidden rounded-xl border border-border bg-panel data-[narrow=true]:w-[300px]">
<ChatHistory
:debug="true"
:saved="hasSavedConversation"
:conversations="conversationOptions"
:selected-id="selectedId"
@select="selectConversation"
@create="newChat"
@rename="renameChat"
@delete="removeChat"
@copy-debug="copyDiagnostics"
/>
<ChatTranscript
v-if="selected"
:key="selected.id"
:messages="selected.messages"
:presentations="presentations"
:status="selected.status"
/>
<p v-if="notice" role="status" class="px-3 py-2 text-xs text-muted">{{ notice }}</p>
<ChatComposer
:key="selectedId"
:status="selected?.status ?? 'ready'"
@submit="submit"
@stop="stop"
@settings="showProfileNotice"
>
<template #model>
<AppSelect
v-model="profile"
label="Mock model profile"
:options="profileOptions"
/>
</template>
</ChatComposer>
</section>
</div>
</TooltipProvider>`
})
} satisfies Meta<Args>
export default meta
type Story = StoryObj<typeof meta>
export const Conversations: Story = {}
export const Interaction: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await userEvent.click(canvas.getByRole('button', { name: 'New fixture' }))
await userEvent.type(
canvas.getByRole('textbox', { name: 'Describe a change' }),
'Make a dashboard'
)
await userEvent.click(canvas.getByRole('button', { name: 'Send message' }))
await expect(canvas.getByText(/local preview response/)).toBeVisible()
const title = canvas.getByRole('textbox', { name: 'Title' })
await expect(title).toHaveValue('Make a dashboard')
await userEvent.clear(title)
await userEvent.type(title, 'My dashboard')
await userEvent.click(canvas.getByRole('button', { name: 'Delete fixture' }))
await expect(title).toHaveValue('Monthly expense dashboard')
await expect(canvas.getByRole('button', { name: 'Create Frame Done' })).toBeVisible()
}
}
export const Empty: Story = { args: { initialChat: 'empty' } }
export const Streaming: Story = { args: { initialChat: 'streaming' } }
export const ToolError: Story = { args: { initialChat: 'error' } }
export const Narrow: Story = { args: { narrow: true } }
export const LongTitle: Story = { args: { initialChat: 'long-title', narrow: true } }

View file

@ -0,0 +1,113 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useScrollFollowing } from './transcript/useScrollFollowing'
import IconButton from '@/components/ui/button/IconButton.vue'
import { ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
import type { ChatStatus, UIMessage } from 'ai'
import { useI18n } from '@open-pencil/vue'
import type { AttachmentPresentation } from '@/app/ai/attachment/presentation/types'
import ChatMessage from './ChatMessage.vue'
import AppPlaceholder from '@/components/ui/feedback/AppPlaceholder.vue'
const {
messages,
status,
showContinue = false
} = defineProps<{
messages: UIMessage[]
status: ChatStatus
showContinue?: boolean
presentations?: Record<string, { text?: string; attachments?: AttachmentPresentation[] }>
}>()
const emit = defineEmits<{ continue: [] }>()
const { ai } = useI18n()
const running = computed(() => status === 'submitted' || status === 'streaming')
const isThinking = computed(() => {
if (!running.value) return false
const last = messages.at(-1)
if (!last || last.role !== 'assistant') return true
const part = last.parts.at(-1)
if (!part || part.type === 'step-start') return true
if ('toolCallId' in part && (part.state === 'output-available' || part.state === 'output-error'))
return true
return status === 'submitted'
})
const transcriptContent = ref<HTMLDivElement>()
const viewportComponent = ref<{ viewportElement?: HTMLElement }>()
const viewport = computed(() => viewportComponent.value?.viewportElement)
const { arrivedState, resumeFollowing } = useScrollFollowing(
viewport,
transcriptContent,
computed(() => status === 'submitted')
)
</script>
<template>
<ScrollAreaRoot class="relative min-h-0 flex-1">
<ScrollAreaViewport ref="viewportComponent" class="h-full px-3 py-3 [&>div]:h-full">
<AppPlaceholder
v-if="messages.length === 0"
data-test-id="chat-empty-state"
:label="ai.describeCreateOrChange"
:ui="{ root: 'h-full' }"
>
<template #icon>
<icon-lucide-message-circle class="size-5" />
</template>
</AppPlaceholder>
<!-- Messages -->
<div v-else ref="transcriptContent" data-test-id="chat-messages" class="flex flex-col gap-3">
<ChatMessage
v-for="(msg, index) in messages"
:key="msg.id"
:message="msg"
:presentation="presentations?.[msg.id]"
:streaming="running && msg.role === 'assistant' && index === messages.length - 1"
/>
<!-- Thinking indicator: shown when AI is working but no visible activity -->
<div v-if="isThinking" data-test-id="chat-typing-indicator" class="flex gap-2">
<div
class="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted/20 text-[10px] font-bold text-muted"
>
AI
</div>
<div class="flex items-center gap-1 py-2">
<span class="size-1.5 animate-bounce rounded-full bg-muted" />
<span
class="size-1.5 animate-bounce rounded-full bg-muted"
:style="{ animationDelay: '150ms' }"
/>
<span
class="size-1.5 animate-bounce rounded-full bg-muted"
:style="{ animationDelay: '300ms' }"
/>
</div>
</div>
<!-- Continue button when step limit reached -->
<div v-if="showContinue" class="flex justify-center py-2">
<button
class="flex items-center gap-1.5 rounded-full bg-accent/10 px-4 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/20"
@click="emit('continue')"
>
<icon-lucide-play class="size-3" />
{{ ai.continueChat }}
</button>
</div>
</div>
</ScrollAreaViewport>
<ScrollAreaScrollbar orientation="vertical" class="flex w-1.5 touch-none p-px select-none">
<ScrollAreaThumb class="relative flex-1 rounded-full bg-muted/30" />
</ScrollAreaScrollbar>
<IconButton
v-if="messages.length && !arrivedState.bottom"
:label="ai.jumpToLatest"
class="absolute right-3 bottom-3 border border-border bg-panel shadow-sm"
@click="resumeFollowing"
><icon-lucide-arrow-down class="size-4"
/></IconButton>
</ScrollAreaRoot>
</template>

View file

@ -0,0 +1,35 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { ReasoningDisplay } from '@/app/settings/preferences/store'
import ReasoningBlock from './ReasoningBlock.vue'
interface Args {
text: string
thinkingLabel: string
reasoningLabel: string
streaming: boolean
display: ReasoningDisplay
}
const meta = {
title: 'Chat/Reasoning',
component: ReasoningBlock,
args: {
text: 'I’ll inspect the selected frame, compare its spacing, and arrange the summary cards.\n\nThe layout should keep a consistent **24 px gap**.',
thinkingLabel: 'Thinking…',
reasoningLabel: 'Reasoning',
streaming: false,
display: 'collapsed'
},
render: (args) => ({
components: { ReasoningBlock },
setup: () => ({ args }),
template: '<div class="w-80"><ReasoningBlock v-bind="args" /></div>'
})
} satisfies Meta<Args>
export default meta
type Story = StoryObj<typeof meta>
export const Collapsed: Story = {}
export const Expanded: Story = { args: { display: 'expanded' } }
export const WhileThinking: Story = { args: { display: 'while-thinking', streaming: true } }

View file

@ -3,26 +3,29 @@ import { useTimeoutFn } from '@vueuse/core'
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui'
import { computed, ref, watch } from 'vue'
import type { ReasoningDisplay } from '@/app/settings/preferences/store'
import ChatMarkdown from '@/components/chat/ChatMarkdown.vue'
const {
display = 'collapsed',
text,
streaming = false,
thinkingLabel,
reasoningLabel
} = defineProps<{
display?: ReasoningDisplay
text: string
streaming?: boolean
thinkingLabel: string
reasoningLabel: string
}>()
const open = ref(streaming)
const open = ref(display === 'expanded' || (display === 'while-thinking' && streaming))
const userChangedOpen = ref(false)
const markdownMode = computed(() => (streaming ? 'streaming' : 'static'))
const { start: scheduleClose, stop: cancelClose } = useTimeoutFn(
() => {
if (!streaming && !userChangedOpen.value) open.value = false
if (display === 'while-thinking' && !streaming && !userChangedOpen.value) open.value = false
},
1000,
{ immediate: false }
@ -32,15 +35,24 @@ watch(
() => streaming,
(isStreaming, wasStreaming) => {
cancelClose()
if (userChangedOpen.value) return
if (isStreaming) {
userChangedOpen.value = false
open.value = true
} else if (wasStreaming) {
open.value = display !== 'collapsed'
} else if (wasStreaming && display === 'while-thinking') {
scheduleClose()
}
}
)
watch(
() => display,
() => {
cancelClose()
if (!userChangedOpen.value)
open.value = display === 'expanded' || (display === 'while-thinking' && streaming)
}
)
function updateOpen(value: boolean): void {
open.value = value
userChangedOpen.value = true
@ -51,6 +63,7 @@ function updateOpen(value: boolean): void {
<template>
<CollapsibleRoot
:open="open"
:unmount-on-hide="false"
class="rounded-lg border border-border bg-canvas"
@update:open="updateOpen"
>
@ -60,18 +73,24 @@ function updateOpen(value: boolean): void {
>
<icon-lucide-brain class="size-3.5 shrink-0 text-accent" aria-hidden="true" />
<span class="flex-1">{{ streaming ? thinkingLabel : reasoningLabel }}</span>
<icon-lucide-loader-circle v-if="streaming" class="size-3 animate-spin" aria-hidden="true" />
<icon-lucide-loader-circle
v-if="streaming"
class="size-3 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
<icon-lucide-chevron-down
v-else
class="size-3 transition-transform [[data-state=open]>&]:rotate-180"
class="size-3 transition-transform motion-reduce:transition-none [[data-state=open]>&]:rotate-180"
aria-hidden="true"
/>
</CollapsibleTrigger>
<CollapsibleContent
data-slot="chat-reasoning-content"
class="data-[state=closed]:collapsible-up data-[state=open]:collapsible-down overflow-hidden border-t border-border px-2 py-1.5 text-[11px] leading-relaxed text-muted"
class="motion-safe:data-[state=closed]:animate-collapsible-up motion-safe:data-[state=open]:animate-collapsible-down animation-duration-150 overflow-hidden"
>
<ChatMarkdown :content="text" :mode="markdownMode" surface="reasoning" />
<div class="border-t border-border px-2 py-1.5 text-[11px] leading-relaxed text-muted">
<ChatMarkdown :content="text" :mode="markdownMode" surface="reasoning" />
</div>
</CollapsibleContent>
</CollapsibleRoot>
</template>

View file

@ -0,0 +1,120 @@
import type { ChatStatus, UIMessage } from 'ai'
export interface Conversation {
id: string
title: string
status: ChatStatus
messages: UIMessage[]
}
export function conversations(): Conversation[] {
return [
{
id: 'dashboard',
title: 'Monthly expense dashboard',
status: 'ready',
messages: [
{
id: 'request',
role: 'user',
parts: [
{ type: 'text', text: 'Build an expense dashboard with summary cards and a chart.' }
]
},
{
id: 'response',
role: 'assistant',
parts: [
{
type: 'reasoning',
text: 'I’ll inspect the frame, then arrange the summary cards and spending chart.',
state: 'done'
},
{
type: 'tool-create_frame',
toolCallId: 'create-dashboard',
state: 'output-available',
input: { name: 'Expenses' },
output: { id: 'frame-42', name: 'Expenses' }
},
{
type: 'text',
text: 'Created the **expense dashboard**.\n\n- Three summary cards\n- Monthly spending chart\n- Recent transactions\n\nWould you like a dark variant?'
}
]
}
]
},
{
id: 'streaming',
title: 'Explore a warmer color palette',
status: 'streaming',
messages: [
{
id: 'palette-request',
role: 'user',
parts: [{ type: 'text', text: 'Try a warmer palette, keeping the text accessible.' }]
},
{
id: 'palette-response',
role: 'assistant',
parts: [
{
type: 'reasoning',
text: 'Comparing contrast ratios against the cream background…',
state: 'streaming'
},
{
type: 'tool-set_fills',
toolCallId: 'palette',
state: 'input-available',
input: { color: '#fef3c7' }
}
]
}
]
},
{
id: 'error',
title: 'Update a layer that no longer exists',
status: 'error',
messages: [
{
id: 'error-request',
role: 'user',
parts: [{ type: 'text', text: 'Rename the selected layer.' }]
},
{
id: 'error-response',
role: 'assistant',
parts: [
{
type: 'tool-rename_node',
toolCallId: 'rename',
state: 'output-error',
input: { id: 'deleted-layer' },
errorText: 'The selected layer no longer exists.'
},
{
type: 'text',
text: 'I couldn’t find that layer. Select another layer and try again.'
}
]
}
]
},
{ id: 'empty', title: 'New chat', status: 'ready', messages: [] },
{
id: 'long-title',
title:
'Review the complete onboarding flow across desktop and mobile, including accessibility and empty states',
status: 'ready',
messages: []
}
]
}
export const profileOptions = [
{ value: 'balanced', label: 'Balanced (mock)' },
{ value: 'fast', label: 'Fast (mock)' }
]

View file

@ -0,0 +1,49 @@
import { onMounted, ref } from 'vue'
import type { AttachmentPresentation } from '@/app/ai/attachment/presentation/types'
export function useAttachments() {
const presentations = ref<Record<string, { attachments: AttachmentPresentation[] }>>({})
onMounted(() => {
const canvas = document.createElement('canvas')
canvas.width = 160
canvas.height = 100
const context = canvas.getContext('2d')
if (!context) return
context.fillStyle = '#fef3c7'
context.fillRect(0, 0, 160, 100)
context.fillStyle = '#9747ff'
context.fillRect(12, 12, 40, 24)
context.fillRect(60, 12, 40, 24)
context.fillRect(108, 12, 40, 24)
canvas.toBlob((preview) => {
if (!preview) return
presentations.value = {
request: {
attachments: [
{
id: 'reference',
messageId: 'request',
kind: 'image',
name: 'reference.png',
mediaType: 'image/png',
originalSize: { x: 160, y: 100 },
preview
},
{
id: 'frame',
messageId: 'request',
kind: 'node',
name: 'Dashboard frame',
nodeId: 'preview-frame',
nodeType: 'FRAME',
originalSize: { x: 160, y: 100 },
preview
}
]
}
}
}, 'image/png')
})
return presentations
}

View file

@ -0,0 +1,102 @@
import { computed, ref } from 'vue'
import { conversations, profileOptions } from './fixtures'
import { useAttachments } from './useAttachments'
export function useConversations(initialChat: string) {
const chats = ref(conversations())
const selectedId = ref(initialChat)
const selected = computed(() => chats.value.find((chat) => chat.id === selectedId.value))
const options = computed(() => chats.value.map((chat) => ({ value: chat.id, label: chat.title })))
const hasSavedConversation = computed(() => Boolean(selected.value?.messages.length))
const conversationOptions = computed(() =>
chats.value.map((chat) => ({
id: chat.id,
title: chat.title,
documentName: 'Demo document',
available: true
}))
)
function selectConversation(id: string) {
selectedId.value = id
}
function copyDiagnostics() {
notice.value = 'Diagnostic copying is mocked in this story.'
}
function showProfileNotice() {
notice.value = 'These are mock profiles. Provider settings are unchanged.'
}
const profile = ref('balanced')
const presentations = useAttachments()
const notice = ref('')
function newChat() {
const id = crypto.randomUUID()
chats.value.push({ id, title: 'New chat', status: 'ready', messages: [] })
selectedId.value = id
}
function removeChat() {
chats.value = chats.value.filter((chat) => chat.id !== selectedId.value)
const first = chats.value.at(0)
if (first) selectedId.value = first.id
else newChat()
}
function renameChat(id: string, title: string) {
const chat = chats.value.find((chat) => chat.id === id)
if (chat) chat.title = title
}
function submit(text: string) {
const chat = selected.value
if (!chat) return
if (!chat.messages.length && chat.title === 'New chat') chat.title = text
chat.messages.push({
id: crypto.randomUUID(),
role: 'user',
parts: [{ type: 'text', text }]
})
chat.messages.push({
id: crypto.randomUUID(),
role: 'assistant',
parts: [
{
type: 'text',
text: 'This is a **local preview response**. No model was called and no document was changed.'
}
]
})
chat.status = 'ready'
}
function stop() {
const chat = selected.value
if (!chat) return
chat.status = 'ready'
for (const message of chat.messages) {
message.parts = message.parts.map((part) => {
if (part.type === 'reasoning') return { ...part, state: 'done' }
if ('toolCallId' in part && part.state === 'input-available')
return { ...part, state: 'output-error', errorText: 'Stopped in the story.' }
return part
})
}
}
return {
chats,
renameChat,
profileOptions,
hasSavedConversation,
conversationOptions,
selectConversation,
copyDiagnostics,
showProfileNotice,
selectedId,
selected,
options,
presentations,
profile,
notice,
newChat,
removeChat,
submit,
stop
}
}

View file

@ -0,0 +1,78 @@
import { useEventListener, useResizeObserver, useScroll } from '@vueuse/core'
import { onScopeDispose, ref, watch, type Ref } from 'vue'
/** Output following is a user-controlled mode, not a distance threshold. */
export function useScrollFollowing(
viewport: Ref<HTMLElement | undefined>,
content: Ref<HTMLElement | undefined>,
submitted: Ref<boolean>
) {
const following = ref(true)
let frame: number | undefined
let userScrolling = false
const { arrivedState, measure } = useScroll(viewport, {
onScroll: () => {
if (userScrolling) following.value = arrivedState.bottom
},
onStop: () => {
userScrolling = false
}
})
function scheduleFollow() {
if (!following.value || frame !== undefined) return
frame = requestAnimationFrame(() => {
frame = undefined
if (!following.value || !viewport.value) return
viewport.value.scrollTop = viewport.value.scrollHeight
measure()
})
}
function resumeFollowing() {
following.value = true
userScrolling = false
scheduleFollow()
}
function beginUserScroll() {
userScrolling = true
following.value = false
}
useEventListener(viewport, 'wheel', beginUserScroll, { passive: true })
useEventListener(viewport, 'touchmove', beginUserScroll, { passive: true })
useEventListener(viewport, 'keydown', (event) => {
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', 'Space'].includes(event.code))
beginUserScroll()
})
// Scrollbar dragging is an explicit navigation gesture; ordinary content clicks are not.
useEventListener(viewport, 'pointerdown', (event) => {
if (event.target === viewport.value) beginUserScroll()
})
useEventListener(
content,
'click',
(event) => {
if (!(event.target instanceof Element)) return
if (event.target.closest('[data-slot="chat-reasoning-trigger"]')) {
following.value = false
}
},
{ capture: true }
)
useResizeObserver([viewport, content], () => {
scheduleFollow()
measure()
})
watch(submitted, (value) => {
if (value) resumeFollowing()
})
watch(viewport, scheduleFollow, { flush: 'post' })
onScopeDispose(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
})
return { following, arrivedState, resumeFollowing }
}

View file

@ -11,6 +11,7 @@ import DiagnosticsSettingsPanel from '@/components/settings/diagnostics/Diagnost
import GeneralSettingsPanel from '@/components/settings/general/GeneralSettingsPanel.vue'
import MCPConnectionsSection from '@/components/settings/mcp/MCPConnectionsSection.vue'
import MCPSettingsPanel from '@/components/settings/mcp/MCPSettingsPanel.vue'
import ChatSettingsSection from '@/components/settings/chat/ChatSettingsSection.vue'
import ModelsPanel from '@/components/settings/models/ModelsPanel.vue'
import StockPhotoKeysSection from '@/components/settings/provider/StockPhotoKeysSection.vue'
import UsageSettingsPanel from '@/components/settings/usage/UsageSettingsPanel.vue'
@ -142,6 +143,7 @@ const navigationClass =
data-test-id="settings-ai-panel"
>
<ModelsPanel />
<ChatSettingsSection />
</section>
<UsageSettingsPanel v-else-if="settingsDialogSection === 'usage'" />

View file

@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '@open-pencil/vue'
import { reasoningDisplay } from '@/app/ai/chat/preferences'
import AppSelect from '@/components/ui/select/AppSelect.vue'
import SettingsGroup from '@/components/settings/layout/SettingsGroup.vue'
import SettingsSectionHeader from '@/components/settings/layout/SettingsSectionHeader.vue'
const { ai } = useI18n()
const options = computed(() => [
{ value: 'collapsed' as const, label: ai.value.reasoningCollapsed },
{ value: 'while-thinking' as const, label: ai.value.reasoningWhileThinking },
{ value: 'expanded' as const, label: ai.value.reasoningExpanded }
])
</script>
<template>
<section class="mt-4 flex flex-col gap-3">
<SettingsSectionHeader>{{ ai.chatSettings }}</SettingsSectionHeader>
<SettingsGroup>
<label class="flex flex-wrap items-center justify-between gap-4 px-3 py-2.5">
<span class="text-xs text-surface">{{ ai.reasoningDisplay }}</span>
<AppSelect
v-model="reasoningDisplay"
:label="ai.reasoningDisplay"
:options="options"
class="w-44"
/>
</label>
</SettingsGroup>
</section>
</template>

27
src/theme/chat/history.ts Normal file
View file

@ -0,0 +1,27 @@
import { tv } from 'tailwind-variants'
export const chatHistoryTheme = tv({
slots: {
root: 'shrink-0 border-b border-border',
header: 'flex min-w-0 items-center gap-1 px-2 py-1.5',
trigger:
'flex min-w-0 flex-1 items-center gap-1 rounded px-1.5 py-1 text-left text-xs text-surface hover:bg-hover disabled:opacity-50',
title: 'min-w-0 flex-1 truncate',
icon: 'size-3.5 shrink-0',
content:
'z-50 w-80 max-w-[calc(100vw-1rem)] rounded-lg border border-border bg-panel p-2 text-surface shadow-lg',
input:
'w-full rounded border border-border bg-input px-2 py-1.5 text-xs outline-none focus:border-accent',
scope: 'my-2 flex gap-1',
scopeButton:
'flex-1 rounded px-2 py-1 text-[11px] text-muted hover:bg-hover data-[active=true]:bg-hover data-[active=true]:text-surface',
list: 'max-h-64 space-y-0.5 overflow-y-auto',
row: 'flex w-full items-center gap-2 rounded px-2 py-2 text-left hover:bg-hover focus-visible:outline-accent data-[selected=true]:bg-hover',
detail: 'block truncate text-[10px] text-muted',
label: 'block truncate text-xs',
empty: 'px-2 py-4 text-center text-xs text-muted',
form: 'space-y-2 border-t border-border p-2',
actions: 'flex justify-end gap-1',
confirmation: 'text-xs text-surface'
}
})

View file

@ -27,6 +27,6 @@ test('Shift+Enter inserts a line break without submitting', async ({ configuredC
test('Enter submits and clears input', async ({ configuredChat: chat }) => {
await chat.submit('Hello there')
await expect(chat.page.getByText('Hello there', { exact: true })).toBeVisible()
await expect(chat.userMessage()).toHaveText('Hello there')
await expect(chat.input).toHaveValue('')
})

View file

@ -0,0 +1,66 @@
import { test, expect } from '#tests/helpers/chat/fixture'
test('diagnostic menu copies the conversation to the clipboard', async ({
configuredChat: chat,
page,
context
}) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await chat.submit('Clipboard regression conversation')
await expect(chat.assistantMessage()).toBeVisible()
await page.getByRole('button', { name: 'Conversation actions' }).click()
await page.getByRole('menuitem', { name: 'Copy diagnostic log' }).click()
await expect(page.getByRole('status')).toContainText('Diagnostic log copied.')
const contents = await page.evaluate(() => navigator.clipboard.readText())
expect(contents).toContain('OPEN PENCIL AI DEBUG LOG')
expect(contents).toContain('Clipboard regression conversation')
})
test('clipboard rejection shows failure feedback rather than success', async ({
configuredChat: chat,
page
}) => {
await chat.submit('Clipboard failure conversation')
await expect(chat.assistantMessage()).toBeVisible()
await page.evaluate(() => {
Object.defineProperty(navigator.clipboard, 'writeText', {
configurable: true,
value: async () => {
throw new DOMException('Permission denied', 'NotAllowedError')
}
})
})
await page.getByRole('button', { name: 'Conversation actions' }).click()
await page.getByRole('menuitem', { name: 'Copy diagnostic log' }).click()
await expect(page.getByRole('status')).toContainText('Could not copy the diagnostic log.')
await expect(page.getByText('Diagnostic log copied.', { exact: true })).toHaveCount(0)
})
test('mobile conversation menu stays attached to its trigger and inside the viewport', async ({
configuredChat: chat,
page
}) => {
await chat.submit('Mobile menu conversation')
await expect(chat.assistantMessage()).toBeVisible()
await page.setViewportSize({ width: 390, height: 844 })
await page.getByTestId('mobile-ribbon-ai').click()
const trigger = page.getByRole('button', { name: 'Conversation actions' })
await expect(trigger).toBeVisible()
const triggerBox = await trigger.boundingBox()
await trigger.click()
const menu = page.getByRole('menu')
await expect(menu).toBeVisible()
await expect
.poll(async () => {
const box = await menu.boundingBox()
if (!box || !triggerBox) return false
return (
box.x >= 0 &&
box.x + box.width <= 390 &&
box.y >= 0 &&
box.y + box.height <= 844 &&
Math.abs(box.x + box.width - triggerBox.x - triggerBox.width) < 2
)
})
.toBe(true)
})

View file

@ -0,0 +1,33 @@
import { test, expect } from '@playwright/test'
test('browser file identity survives reopening and distinguishes same-named files', async ({
page
}) => {
await page.goto('/')
const ids = await page.evaluate(async () => {
const modulePath = '/src/app/ai/chat/history/idb.ts'
const { createConversationStore } = await import(modulePath)
const root = await navigator.storage.getDirectory()
const directoryName = `chat-identity-${crypto.randomUUID()}`
const directory = await root.getDirectoryHandle(directoryName, { create: true })
try {
const firstDirectory = await directory.getDirectoryHandle('first', { create: true })
const secondDirectory = await directory.getDirectoryHandle('second', { create: true })
const file = await firstDirectory.getFileHandle('design.fig', { create: true })
const concurrent = await Promise.all(
Array.from({ length: 12 }, () => createConversationStore().resolveFile(file))
)
const first = concurrent[0]
const reopened = await firstDirectory.getFileHandle('design.fig')
const second = await createConversationStore().resolveFile(reopened)
const other = await secondDirectory.getFileHandle('design.fig', { create: true })
const third = await createConversationStore().resolveFile(other)
return { first, second, third, concurrent }
} finally {
await root.removeEntry(directoryName, { recursive: true })
}
})
expect(new Set(ids.concurrent).size).toBe(1)
expect(ids.first).toBe(ids.second)
expect(ids.third).not.toBe(ids.first)
})

View file

@ -0,0 +1,55 @@
import { test, expect } from '#tests/helpers/chat/fixture'
test('multiple conversations preserve messages and manual titles', async ({
configuredChat: chat,
page
}) => {
await chat.submit('Create the first dashboard')
await expect(chat.assistantMessage()).toBeVisible()
await page.getByRole('button', { name: 'New chat', exact: true }).first().click()
await expect(chat.input).toBeVisible()
await expect(page.getByTestId('chat-message-user')).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Conversation actions' })).toHaveCount(0)
await expect(page.getByRole('checkbox', { name: 'All chats' })).toHaveCount(0)
await chat.submit('Create the second dashboard')
await expect(chat.assistantMessage()).toBeVisible()
await page.getByRole('button', { name: 'Conversation history', exact: true }).click()
await page.getByRole('textbox', { name: 'Search conversations…' }).fill('first dashboard')
await expect(
page.getByRole('button', { name: 'Create the second dashboard', exact: true })
).toHaveCount(0)
await page.getByRole('button', { name: 'Create the first dashboard', exact: true }).click()
await expect(chat.userMessage()).toContainText('Create the first dashboard')
const actions = page.getByRole('button', { name: 'Conversation actions' })
const triggerBox = await actions.boundingBox()
await actions.click()
const menuBox = await page.getByRole('menu').boundingBox()
expect(triggerBox).not.toBeNull()
expect(menuBox).not.toBeNull()
if (!triggerBox || !menuBox) throw new Error('Missing menu geometry')
expect(Math.abs(menuBox.x + menuBox.width - triggerBox.x - triggerBox.width)).toBeLessThan(2)
expect(Math.abs(menuBox.y - triggerBox.y - triggerBox.height - 6)).toBeLessThan(2)
await expect(page.getByRole('menuitem', { name: 'Copy diagnostic log' })).toBeVisible()
await page.getByRole('menuitem', { name: 'Rename', exact: true }).click()
await page.getByRole('textbox', { name: 'Conversation title' }).fill('First design')
await page.getByRole('button', { name: 'Save', exact: true }).click()
await expect(
page.getByRole('button', { name: 'Conversation history', exact: true })
).toContainText('First design')
})
test('saved transcripts remain readable after reload without provider setup', async ({
configuredChat: chat,
page
}) => {
await chat.submit('A durable conversation')
await expect(chat.assistantMessage()).toBeVisible()
await page.getByRole('button', { name: 'New chat', exact: true }).first().click()
await page.reload()
await chat.chatTab.click()
await page.getByRole('button', { name: 'Conversation history', exact: true }).click()
await page.getByRole('button', { name: 'All documents', exact: true }).click()
await page.getByRole('button', { name: /A durable conversation/ }).click()
await expect(chat.userMessage()).toContainText('A durable conversation')
await expect(chat.assistantMessage()).toBeVisible()
})

View file

@ -0,0 +1,91 @@
import { test, expect } from '#tests/helpers/chat/fixture'
import { finishReasoning, installReasoningTransport } from '#tests/helpers/chat/reasoning'
test('expand while thinking auto-collapses but preserves manual opening', async ({
configuredChat: chat,
page
}) => {
await page.getByTestId('provider-settings-trigger').click()
await page.getByRole('combobox', { name: 'Reasoning display' }).click()
await page.getByRole('option', { name: 'Expand while thinking', exact: true }).click()
await page.getByTestId('app-settings-done').click()
await installReasoningTransport(page)
await chat.submit('Inspect the layout')
const trigger = page.locator('[data-slot="chat-reasoning-trigger"]').last()
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
await finishReasoning(page)
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
await chat.submit('Inspect it again')
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
await trigger.click()
await trigger.click()
await finishReasoning(page)
await expect(chat.assistantMessage()).toContainText('Finished inspecting')
await page.waitForTimeout(1200)
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
})
test('collapsed reasoning respects reduced motion and does not scroll old content to the bottom', async ({
configuredChat: chat,
page
}) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await chat.submit('Show reasoning')
const trigger = page.locator('[data-slot="chat-reasoning-trigger"]').first()
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
await chat.submit(Array.from({ length: 60 }, (_, i) => `Follow-up line ${i}`).join('\n'))
await expect(chat.assistantMessage()).toBeVisible()
const viewport = page.getByTestId('chat-panel').locator('[data-reka-scroll-area-viewport]')
await viewport.evaluate((element) => {
element.scrollTop = 0
})
await trigger.click()
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
const content = page.locator('[data-slot="chat-reasoning-content"]').first()
expect(await content.evaluate((element) => getComputedStyle(element).animationName)).toBe('none')
await expect
.poll(() =>
viewport.evaluate(
(element) => element.scrollHeight - element.scrollTop - element.clientHeight
)
)
.toBeGreaterThan(500)
expect(await viewport.evaluate((element) => element.scrollTop)).toBeLessThan(30)
await page.getByRole('button', { name: 'Jump to latest' }).click()
await expect
.poll(() =>
viewport.evaluate(
(element) => element.scrollHeight - element.scrollTop - element.clientHeight
)
)
.toBeLessThan(3)
})
test('reasoning preferences use AI settings and persist across reload', async ({
configuredChat: chat,
page
}) => {
await page.getByTestId('provider-settings-trigger').click()
const display = page.getByRole('combobox', { name: 'Reasoning display' })
await expect(display).toContainText('Collapsed by default')
await display.click()
await page.getByRole('option', { name: 'Expanded by default', exact: true }).click()
await page.getByTestId('app-settings-done').click()
await chat.submit('Show your reasoning')
const trigger = page.locator('[data-slot="chat-reasoning-trigger"]')
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
await trigger.click()
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
await expect
.poll(() =>
page
.locator('[data-slot="chat-reasoning-content"]')
.evaluate((element) => getComputedStyle(element).animationName)
)
.toBe('collapsible-up')
await expect(page.locator('[data-slot="chat-reasoning-content"]')).toBeHidden()
await page.reload()
await chat.chatTab.click()
await page.getByTestId('provider-settings-trigger').click()
await expect(display).toContainText('Expanded by default')
})

View file

@ -0,0 +1,30 @@
import { test, expect } from '#tests/helpers/chat/fixture'
test('recovering an unsaved document restores its selected conversation automatically', async ({
configuredChat: chat,
page
}) => {
await page.evaluate(async () => {
const editor = window.openPencil?.getStore?.()
if (!editor) throw new Error('Missing editor')
editor.createShape('RECTANGLE', 100, 100, 200, 100)
await editor.persistRecoveryNow()
})
await chat.submit('Conversation belonging to recovered document')
await expect(chat.assistantMessage()).toBeVisible()
// Switching conversations forces a durable flush; choose the original before reload.
await page.getByRole('button', { name: 'New chat', exact: true }).click()
await page.getByRole('button', { name: 'Conversation history', exact: true }).click()
await page
.getByRole('button', { name: 'Conversation belonging to recovered document', exact: true })
.click()
await page.reload()
await expect(page.getByRole('alertdialog', { name: 'Recover unsaved work' })).toBeVisible()
await page.getByRole('button', { name: 'Restore', exact: true }).click()
await chat.chatTab.click()
await expect(chat.userMessage()).toContainText('Conversation belonging to recovered document')
await expect(chat.assistantMessage()).toBeVisible()
await expect(
page.getByRole('button', { name: 'Conversation history', exact: true })
).toContainText('Conversation belonging to recovered document')
})

View file

@ -0,0 +1,33 @@
import { test, expect } from '#tests/helpers/chat/fixture'
test('a long restored conversation scrolls to its latest message on mount', async ({
configuredChat: chat,
page
}) => {
const text = Array.from({ length: 100 }, (_, index) => `Restored line ${index}`).join('\n')
await chat.submit(text)
await expect(chat.assistantMessage()).toBeVisible()
await page.reload()
await chat.chatTab.click()
await page.getByRole('button', { name: 'Conversation history', exact: true }).click()
await page.getByRole('button', { name: 'All documents', exact: true }).click()
await page.getByRole('button', { name: /Restored line 0/ }).click()
await chat.designTab.click()
await chat.chatTab.click()
const viewport = page.getByTestId('chat-panel').locator('[data-reka-scroll-area-viewport]')
await expect
.poll(() =>
viewport.evaluate((element) => ({
overflows: element.scrollHeight > element.clientHeight,
bottomGap: element.scrollHeight - element.scrollTop - element.clientHeight
}))
)
.toMatchObject({ overflows: true })
await expect
.poll(() =>
viewport.evaluate(
(element) => element.scrollHeight - element.scrollTop - element.clientHeight
)
)
.toBeLessThan(3)
})

View file

@ -0,0 +1,70 @@
import { expect, test } from 'bun:test'
import type { HarnessSidecarMessage } from '@open-pencil/harness'
import { HarnessChatTransport } from '@/app/ai/harness/transport'
test('failed session stop kills the process without deleting resume state', async () => {
let output: ReadableStreamDefaultController<HarnessSidecarMessage> | undefined
const methods: string[] = []
let kills = 0
const transport = new HarnessChatTransport(
'test-session',
{
adapter: 'pi',
sandbox: 'just-bash',
model: 'test',
settings: {},
instructions: '',
mcpServers: []
},
{},
async () => ({
child: {
write: async () => undefined,
kill: async () => {
kills++
output?.close()
}
},
messages: new ReadableStream({
start(controller) {
output = controller
}
}),
send: async (request) => {
if (
!('id' in request) ||
!('method' in request) ||
typeof request.id !== 'string' ||
typeof request.method !== 'string'
)
throw new Error('Invalid request')
methods.push(request.method)
output?.enqueue({
type: 'response',
id: request.id,
...(request.method === 'session.stop'
? { error: 'Resume storage failed' }
: { result: {} })
})
}
})
)
const stream = await transport.sendMessages({
chatId: 'test',
trigger: 'submit-message',
messageId: undefined,
messages: [{ id: 'user', role: 'user', parts: [{ type: 'text', text: 'Hello' }] }]
})
const reader = stream.getReader()
while (!(await reader.read()).done) {
/* Drain the completed turn. */
}
await expect(transport.stop()).rejects.toThrow('Resume storage failed')
expect(kills).toBe(1)
expect(methods).toContain('session.stop')
expect(methods).not.toContain('session.destroy')
await transport.stop()
expect(kills).toBe(1)
})

View file

@ -30,6 +30,12 @@ describe('Portless MCP routing', () => {
})
})
test('preserves a nonstandard HTTPS proxy port', () => {
const route = devAutomationRoute('https://chat-history.open-pencil.localhost:1355', 7600)
expect(route.browserURL).toBe('wss://chat-history.mcp.open-pencil.localhost:1355')
expect(route.corsOrigin).toBe('https://chat-history.open-pencil.localhost:1355')
})
test('rejects unrelated Portless hostnames', () => {
expect(() => devAutomationRoute('https://other.localhost', 7600)).toThrow(
'Unexpected OpenPencil Portless URL'

View file

@ -0,0 +1,48 @@
import { expect, test } from 'bun:test'
import type { UIMessage } from 'ai'
import { resumableTransport } from '@/app/ai/chat/history/continuation'
test('continuation omits unfinished tool calls without altering the saved transcript', async () => {
const messages: UIMessage[] = [
{
id: 'partial',
role: 'assistant',
parts: [
{ type: 'text', text: 'Working on it' },
{ type: 'tool-edit', toolCallId: 'pending', state: 'input-available', input: {} },
{
type: 'tool-read',
toolCallId: 'finished',
state: 'output-available',
input: {},
output: {}
}
]
}
]
let sent: UIMessage[] = []
const transport = resumableTransport({
reconnectToStream: async () => null,
sendMessages: async (options) => {
sent = options.messages
return new ReadableStream({
start(controller) {
controller.close()
}
})
}
})
await transport.sendMessages({
chatId: 'chat',
trigger: 'submit-message',
messageId: undefined,
messages
})
expect(sent[0]?.parts).toHaveLength(2)
expect(messages[0]?.parts).toHaveLength(3)
expect(
sent[0]?.parts.some((part) => 'toolCallId' in part && part.toolCallId === 'finished')
).toBe(true)
})

View file

@ -0,0 +1,180 @@
import 'fake-indexeddb/auto'
import { expect, test } from 'bun:test'
import type { UIMessage } from 'ai'
import { reactive } from 'vue'
import { createConversationHistory } from '@/app/ai/chat/history/controller'
import type { ChatDocumentEditor } from '@/app/ai/chat/history/document'
import { createConversationStore } from '@/app/ai/chat/history/idb'
function editor(): ChatDocumentEditor {
const id = crypto.randomUUID()
return {
state: { documentName: 'Test document' },
getRecoveryId: () => id,
getSourceIdentity: () => ({ path: null, handle: null }),
getDocumentFilePath: () => null,
getStorageBinding: () => null
}
}
test('adopting recovery in the same editor loads its selection instead of moving old chats', async () => {
const store = createConversationStore()
const active = editor()
const history = createConversationHistory(
{
getEditor: () => active,
ensureChat: async () => null,
profileId: () => null,
resetChat: async () => undefined,
backend: () => 'direct'
},
store
)
await history.initialize()
const original = history.current.value
if (!original) throw new Error('Missing draft')
await history.rename(original.id, 'Existing chat')
const recoveryId = crypto.randomUUID()
const restoredId = crypto.randomUUID()
await store.write({
...original,
id: restoredId,
documentId: `recovery:${recoveryId}`,
title: 'Recovered chat',
titleSource: 'manual'
})
await store.select(`recovery:${recoveryId}`, restoredId)
active.getRecoveryId = () => recoveryId
await history.initialize()
expect(history.current.value?.id).toBe(restoredId)
expect((await store.read(original.id))?.documentId).toBe(original.documentId)
})
function fixture() {
let activeEditor = editor()
let failWrites = false
let resets = 0
const store = createConversationStore()
const chat = reactive({
messages: [] as UIMessage[],
status: 'ready' as const,
stop: async () => undefined
})
const history = createConversationHistory(
{
getEditor: () => activeEditor,
ensureChat: async () => chat,
resetChat: async () => {
resets++
},
profileId: () => null,
backend: () => 'direct'
},
{
...store,
write: async (row) => {
if (failWrites) throw new DOMException('Storage full', 'QuotaExceededError')
await store.write(row)
}
}
)
return {
history,
store,
chat,
setFail: (value: boolean) => {
failWrites = value
},
getResets: () => resets,
switchEditor: () => {
activeEditor = editor()
}
}
}
test('failed flush keeps the current conversation and blocks switching until retry succeeds', async () => {
const f = fixture()
await f.history.ensureChat()
const id = f.history.current.value?.id
f.chat.messages = [
{ id: 'message', role: 'user', parts: [{ type: 'text', text: 'Keep this draft' }] }
]
f.setFail(true)
await expect(f.history.newChat()).rejects.toThrow('Storage full')
expect(f.history.current.value?.id).toBe(id)
expect(f.history.messages.value[0]?.id).toBe('message')
expect(f.history.storageError.value).toBe(true)
f.setFail(false)
await f.history.newChat()
expect(f.history.current.value?.id).not.toBe(id)
expect(f.history.storageError.value).toBe(false)
if (!id) throw new Error('Missing conversation')
expect((await f.store.read(id))?.messages[0]?.message.id).toBe('message')
})
test('queued chat switches finish on the last requested conversation without losing transcripts', async () => {
const f = fixture()
await f.history.initialize()
const first = f.history.current.value?.id
if (first) await f.history.rename(first, 'First')
await f.history.newChat()
const second = f.history.current.value?.id
if (second) await f.history.rename(second, 'Second')
if (!first || !second) throw new Error('Missing conversation')
await Promise.all(
Array.from({ length: 20 }, (_, index) => f.history.open(index % 2 === 0 ? first : second))
)
expect(f.history.current.value?.id).toBe(second)
expect(await f.store.getSelected(f.history.current.value.documentId)).toBe(second)
expect(await f.store.read(first)).not.toBeNull()
expect(f.history.busy.value).toBe(false)
})
test('opening the panel and repeatedly creating drafts does not persist empty chats', async () => {
const f = fixture()
await f.history.initialize()
const documentId = f.history.current.value?.documentId
await f.history.newChat()
await f.history.newChat()
expect(await f.store.list(documentId)).toEqual([])
if (!documentId) throw new Error('Missing document')
expect(await f.store.getSelected(documentId)).toBeNull()
})
test('a pending transport is rejected when the active document changes', async () => {
let activeEditor = editor()
const pending = Promise.withResolvers<{
messages: UIMessage[]
status: 'ready'
stop: () => Promise<void>
}>()
const started = Promise.withResolvers<undefined>()
let stops = 0
const history = createConversationHistory(
{
getEditor: () => activeEditor,
ensureChat: () => {
started.resolve(undefined)
return pending.promise
},
profileId: () => null,
resetChat: async () => undefined,
backend: () => 'direct'
},
createConversationStore()
)
const initializing = history.ensureChat()
await started.promise
activeEditor = editor()
pending.resolve({
messages: [],
status: 'ready',
stop: async () => {
stops++
}
})
expect(await initializing).toBeNull()
expect(stops).toBe(1)
})

View file

@ -0,0 +1,123 @@
import 'fake-indexeddb/auto'
import { describe, expect, test } from 'bun:test'
import { createConversationStore } from '@/app/ai/chat/history/idb'
import type { Conversation } from '@/app/ai/chat/history/types'
function conversation(): Conversation {
return {
id: crypto.randomUUID(),
documentId: crypto.randomUUID(),
documentName: 'Dashboard',
title: 'Create a dashboard',
titleSource: 'fallback',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
profileId: null,
backend: 'direct',
interrupted: false,
messages: [
{
message: { id: 'user', role: 'user', parts: [{ type: 'text', text: 'Model context' }] },
displayText: 'Create a dashboard',
attachments: [
{
id: 'image',
messageId: 'user',
kind: 'image',
name: 'reference.png',
mediaType: 'image/png',
originalSize: { x: 1, y: 1 },
preview: new Blob(['image'], { type: 'image/png' })
}
]
}
]
}
}
describe('conversation IndexedDB storage', () => {
test('restores messages, presentation, blobs and selected conversation through a new store', async () => {
const first = createConversationStore()
const row = conversation()
await first.write(row)
await first.select(row.documentId, row.id)
const reopened = createConversationStore()
const restored = await reopened.read(row.id)
expect(restored?.messages[0].message).toEqual(row.messages[0].message)
expect(restored?.messages[0].displayText).toBe('Create a dashboard')
expect(await restored?.messages[0].attachments[0].preview.text()).toBe('image')
expect(await reopened.getSelected(row.documentId)).toBe(row.id)
expect((await reopened.list(row.documentId)).map((meta) => meta.id)).toEqual([row.id])
})
test('manual rename survives a delayed fallback snapshot', async () => {
const store = createConversationStore()
const row = conversation()
await store.write(row)
await store.rename(row.id, ' My dashboard ')
await store.write(row)
expect((await store.read(row.id))?.title).toBe('My dashboard')
expect((await store.read(row.id))?.titleSource).toBe('manual')
})
test('selection cannot bind a conversation to the wrong document', async () => {
const store = createConversationStore()
const row = conversation()
await store.write(row)
expect(store.select('another-document', row.id)).rejects.toThrow('different document')
expect(await store.getSelected('another-document')).toBeNull()
})
test('saving an untitled document moves all chats and its selection atomically', async () => {
const store = createConversationStore()
const row = conversation()
const second = { ...conversation(), documentId: row.documentId }
await store.write(row)
await store.write(second)
await store.select(row.documentId, second.id)
const target = `file:${crypto.randomUUID()}`
await store.reassignDocument(row.documentId, target, 'Saved document')
expect(await store.list(row.documentId)).toEqual([])
expect((await store.list(target)).map((meta) => meta.documentName)).toEqual([
'Saved document',
'Saved document'
])
await store.write(row)
expect((await store.read(row.id))?.documentId).toBe(target)
expect(await store.getSelected(target)).toBe(second.id)
expect(await store.getSelected(row.documentId)).toBeNull()
})
test('a stale writer cannot resurrect a deleted conversation', async () => {
const store = createConversationStore()
const staleWriter = createConversationStore()
const row = conversation()
await store.write(row)
await store.remove(row.id)
await expect(staleWriter.write(row)).rejects.toThrow('deleted')
expect(await store.read(row.id)).toBeNull()
})
test('an older manual title cannot overwrite a newer rename', async () => {
const store = createConversationStore()
const row = { ...conversation(), title: 'Old title', titleSource: 'manual' as const }
await store.write(row)
await store.rename(row.id, 'New title')
await store.write(row)
expect((await store.read(row.id))?.title).toBe('New title')
})
test('deletion removes content and active selection without affecting other chats', async () => {
const store = createConversationStore()
const row = conversation()
const other = { ...conversation(), documentId: row.documentId }
await store.write(row)
await store.write(other)
await store.select(row.documentId, row.id)
await store.remove(row.id)
expect(await store.read(row.id)).toBeNull()
expect(await store.getSelected(row.documentId)).toBeNull()
expect((await store.list(row.documentId)).map((meta) => meta.id)).toEqual([other.id])
})
})

View file

@ -0,0 +1,45 @@
import type { Page } from '@playwright/test'
/** Hold reasoning open until the test explicitly completes the turn. */
export async function installReasoningTransport(page: Page): Promise<void> {
await page.evaluate(() => {
window.openPencil?.setChatTransport(() => ({
async sendMessages() {
return new ReadableStream({
start(controller) {
controller.enqueue({ type: 'start', messageId: crypto.randomUUID() })
controller.enqueue({ type: 'reasoning-start', id: 'thinking' })
controller.enqueue({
type: 'reasoning-delta',
id: 'thinking',
delta: 'Inspecting the selected layout.'
})
window.addEventListener(
'finish-test-reasoning',
() => {
controller.enqueue({ type: 'reasoning-end', id: 'thinking' })
controller.enqueue({ type: 'text-start', id: 'answer' })
controller.enqueue({
type: 'text-delta',
id: 'answer',
delta: 'Finished inspecting the layout.'
})
controller.enqueue({ type: 'text-end', id: 'answer' })
controller.enqueue({ type: 'finish', finishReason: 'stop' })
controller.close()
},
{ once: true }
)
}
})
},
async reconnectToStream() {
return null
}
}))
})
}
export async function finishReasoning(page: Page): Promise<void> {
await page.evaluate(() => window.dispatchEvent(new Event('finish-test-reasoning')))
}

View file

@ -216,3 +216,6 @@ zh-cn:updates.downloading
zh-cn:updates.installed
zh-cn:updates.unavailable
zh-cn:updates.upToDate
ja:ai.copyACPLog
zh-cn:ai.copyACPLog

View file

@ -230,3 +230,7 @@ zh-cn:panels.maskTypeAlpha
zh-cn:storage.providerAmazonS3
zh-cn:storage.providerBackblaze
zh-cn:storage.providerR2
de:ai.chatSettings
es:ai.chatSettings
it:ai.chatSettings