-
-
-
-
- {(title) => (
-
- {title}
-
- )}
-
-
-
- {language.t("common.loading")}
- {language.t("common.loading.ellipsis")}
+
+
size.start()}>
+ {
+ size.touch()
+ layout.terminal.resize(next)
+ }}
+ onCollapse={close}
+ />
+
+
+
+
+ {(title) => (
+
+ {title}
+
+ )}
+
+
+
+ {language.t("common.loading")}
+ {language.t("common.loading.ellipsis")}
+
+
+
+ {language.t("terminal.loading")}
-
{language.t("terminal.loading")}
-
- }
- >
-
-
-
-
-
terminal.open(id)}
- class="!h-auto !flex-none"
- >
-
-
-
- {(id) => (
-
- {(pty) => }
-
- )}
-
-
-
-
-
-
-
-
-
-
-
- {(id) => (
-
- {(pty) => (
-
-
terminal.clone(id)} />
+
+
+
+
+
terminal.open(id)}
+ class="!h-auto !flex-none"
+ >
+
+
+
+ {(id) => (
+
+ {(pty) => }
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {(id) => (
+
+ {(pty) => (
+
+ terminal.clone(id)}
+ />
+
+ )}
+
+ )}
+
+
+
+
+
+ {(draggedId) => (
+
+ {(t) => (
+
+ {terminalTabLabel({
+ title: t().title,
+ titleNumber: t().titleNumber,
+ t: language.t as (key: string, vars?: Record) => string,
+ })}
)}
)}
-
-
-
-
- {(draggedId) => (
-
- {(t) => (
-
- {terminalTabLabel({
- title: t.title,
- titleNumber: t.titleNumber,
- t: language.t as (key: string, vars?: Record) => string,
- })}
-
- )}
-
- )}
-
-
-
-
+
+
+
+
)
diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx
index 461351878b..b8ddeda823 100644
--- a/packages/app/src/pages/session/use-session-commands.tsx
+++ b/packages/app/src/pages/session/use-session-commands.tsx
@@ -261,24 +261,35 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}),
])
+ const isAutoAcceptActive = () => {
+ const sessionID = params.id
+ if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
+ return permission.isAutoAcceptingDirectory(sdk.directory)
+ }
+
const permissionCommands = createMemo(() => [
permissionsCommand({
id: "permissions.autoaccept",
- title:
- params.id && permission.isAutoAccepting(params.id, sdk.directory)
- ? language.t("command.permissions.autoaccept.disable")
- : language.t("command.permissions.autoaccept.enable"),
+ title: isAutoAcceptActive()
+ ? language.t("command.permissions.autoaccept.disable")
+ : language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
- disabled: !params.id || !permission.permissionsEnabled(),
+ disabled: false,
onSelect: () => {
const sessionID = params.id
- if (!sessionID) return
- permission.toggleAutoAccept(sessionID, sdk.directory)
+ if (sessionID) {
+ permission.toggleAutoAccept(sessionID, sdk.directory)
+ } else {
+ permission.toggleAutoAcceptDirectory(sdk.directory)
+ }
+ const active = sessionID
+ ? permission.isAutoAccepting(sessionID, sdk.directory)
+ : permission.isAutoAcceptingDirectory(sdk.directory)
showToast({
- title: permission.isAutoAccepting(sessionID, sdk.directory)
+ title: active
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
- description: permission.isAutoAccepting(sessionID, sdk.directory)
+ description: active
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
})
diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts
index 20e88a3ea3..278a1ba6e5 100644
--- a/packages/app/src/pages/session/use-session-hash-scroll.ts
+++ b/packages/app/src/pages/session/use-session-hash-scroll.ts
@@ -1,6 +1,5 @@
import type { UserMessage } from "@opencode-ai/sdk/v2"
-import { useLocation, useNavigate } from "@solidjs/router"
-import { createEffect, createMemo, onMount } from "solid-js"
+import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { messageIdFromHash } from "./message-id-from-hash"
export { messageIdFromHash } from "./message-id-from-hash"
@@ -16,7 +15,7 @@ export const useSessionHashScroll = (input: {
setPendingMessage: (value: string | undefined) => void
setActiveMessage: (message: UserMessage | undefined) => void
setTurnStart: (value: number) => void
- autoScroll: { pause: () => void; forceScrollToBottom: () => void }
+ autoScroll: { pause: () => void; snapToBottom: () => void }
scroller: () => HTMLDivElement | undefined
anchor: (id: string) => string
scheduleScrollState: (el: HTMLDivElement) => void
@@ -27,18 +26,13 @@ export const useSessionHashScroll = (input: {
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
let pendingKey = ""
- const location = useLocation()
- const navigate = useNavigate()
-
const clearMessageHash = () => {
- if (!location.hash) return
- navigate(location.pathname + location.search, { replace: true })
+ if (!window.location.hash) return
+ window.history.replaceState(null, "", window.location.pathname + window.location.search)
}
const updateHash = (id: string) => {
- navigate(location.pathname + location.search + `#${input.anchor(id)}`, {
- replace: true,
- })
+ window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`)
}
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
@@ -47,15 +41,15 @@ export const useSessionHashScroll = (input: {
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
- const sticky = root.querySelector("[data-session-title]")
- const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
- const top = Math.max(0, a.top - b.top + root.scrollTop - inset)
+ const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
+ const inset = Number.isNaN(title) ? 0 : title
+ // With column-reverse, scrollTop is negative — don't clamp to 0
+ const top = a.top - b.top + root.scrollTop - inset
root.scrollTo({ top, behavior })
return true
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
- console.log({ message, behavior })
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
const index = messageIndex().get(message.id) ?? -1
@@ -103,9 +97,9 @@ export const useSessionHashScroll = (input: {
}
const applyHash = (behavior: ScrollBehavior) => {
- const hash = location.hash.slice(1)
+ const hash = window.location.hash.slice(1)
if (!hash) {
- input.autoScroll.forceScrollToBottom()
+ input.autoScroll.snapToBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
return
@@ -129,13 +123,26 @@ export const useSessionHashScroll = (input: {
return
}
- input.autoScroll.forceScrollToBottom()
+ input.autoScroll.snapToBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
}
+ onMount(() => {
+ if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
+ window.history.scrollRestoration = "manual"
+ }
+
+ const handler = () => {
+ if (!input.sessionID() || !input.messagesReady()) return
+ requestAnimationFrame(() => applyHash("auto"))
+ }
+
+ window.addEventListener("hashchange", handler)
+ onCleanup(() => window.removeEventListener("hashchange", handler))
+ })
+
createEffect(() => {
- location.hash
if (!input.sessionID() || !input.messagesReady()) return
requestAnimationFrame(() => applyHash("auto"))
})
@@ -159,7 +166,6 @@ export const useSessionHashScroll = (input: {
}
}
- if (!targetId) targetId = messageIdFromHash(location.hash)
if (!targetId) return
if (input.currentMessageId() === targetId) return
@@ -171,12 +177,6 @@ export const useSessionHashScroll = (input: {
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
})
- onMount(() => {
- if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
- window.history.scrollRestoration = "manual"
- }
- })
-
return {
clearMessageHash,
scrollToMessage,
diff --git a/packages/console/app/package.json b/packages/console/app/package.json
index 269b005a86..0032a24319 100644
--- a/packages/console/app/package.json
+++ b/packages/console/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
- "version": "1.2.19",
+ "version": "1.2.21",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts
index e64d364620..19e331c39a 100644
--- a/packages/console/app/src/config.ts
+++ b/packages/console/app/src/config.ts
@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
- compact: "100K",
- full: "100,000",
+ compact: "120K",
+ full: "120,000",
},
},
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page)
stats: {
- contributors: "700",
- commits: "9,000",
- monthlyUsers: "2.5M",
+ contributors: "800",
+ commits: "10,000",
+ monthlyUsers: "5M",
},
} as const
diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts
index 89fd39b931..86d51226a6 100644
--- a/packages/console/app/src/i18n/ar.ts
+++ b/packages/console/app/src/i18n/ar.ts
@@ -480,7 +480,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(محذوف)",
"workspace.cost.empty": "لا توجد بيانات استخدام متاحة للفترة المحددة.",
"workspace.cost.subscriptionShort": "اشتراك",
- "workspace.cost.liteShort": "lite",
"workspace.keys.title": "مفاتيح API",
"workspace.keys.subtitle": "إدارة مفاتيح API الخاصة بك للوصول إلى خدمات opencode.",
diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts
index d7d9191729..f14a69c85c 100644
--- a/packages/console/app/src/i18n/br.ts
+++ b/packages/console/app/src/i18n/br.ts
@@ -488,7 +488,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(excluído)",
"workspace.cost.empty": "Nenhum dado de uso disponível para o período selecionado.",
"workspace.cost.subscriptionShort": "ass",
- "workspace.cost.liteShort": "lite",
"workspace.keys.title": "Chaves de API",
"workspace.keys.subtitle": "Gerencie suas chaves de API para acessar os serviços opencode.",
diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts
index 919f9c646a..775f029fba 100644
--- a/packages/console/app/src/i18n/da.ts
+++ b/packages/console/app/src/i18n/da.ts
@@ -484,7 +484,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(slettet)",
"workspace.cost.empty": "Ingen brugsdata tilgængelige for den valgte periode.",
"workspace.cost.subscriptionShort": "sub",
- "workspace.cost.liteShort": "lite",
"workspace.keys.title": "API-nøgler",
"workspace.keys.subtitle": "Administrer dine API-nøgler for at få adgang til opencode-tjenester.",
diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts
index 082d66bbe3..2d9be14ff7 100644
--- a/packages/console/app/src/i18n/de.ts
+++ b/packages/console/app/src/i18n/de.ts
@@ -487,7 +487,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(gelöscht)",
"workspace.cost.empty": "Keine Nutzungsdaten für den gewählten Zeitraum verfügbar.",
"workspace.cost.subscriptionShort": "Abo",
- "workspace.cost.liteShort": "lite",
"workspace.keys.title": "API Keys",
"workspace.keys.subtitle": "Verwalte deine API Keys für den Zugriff auf OpenCode-Dienste.",
diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts
index 19e1cdefdb..2a279757b3 100644
--- a/packages/console/app/src/i18n/en.ts
+++ b/packages/console/app/src/i18n/en.ts
@@ -480,7 +480,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(deleted)",
"workspace.cost.empty": "No usage data available for the selected period.",
"workspace.cost.subscriptionShort": "sub",
- "workspace.cost.liteShort": "lite",
"workspace.keys.title": "API Keys",
"workspace.keys.subtitle": "Manage your API keys for accessing opencode services.",
diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts
index c1bfdeeb77..25b8d37d7c 100644
--- a/packages/console/app/src/i18n/es.ts
+++ b/packages/console/app/src/i18n/es.ts
@@ -489,7 +489,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(eliminado)",
"workspace.cost.empty": "No hay datos de uso disponibles para el periodo seleccionado.",
"workspace.cost.subscriptionShort": "sub",
- "workspace.cost.liteShort": "lite",
"workspace.keys.title": "Claves API",
"workspace.keys.subtitle": "Gestiona tus claves API para acceder a los servicios de opencode.",
diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts
index 6d8134afbe..ddf33c0ec2 100644
--- a/packages/console/app/src/i18n/fr.ts
+++ b/packages/console/app/src/i18n/fr.ts
@@ -490,7 +490,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(supprimé)",
"workspace.cost.empty": "Aucune donnée d'utilisation disponible pour la période sélectionnée.",
"workspace.cost.subscriptionShort": "abo",
- "workspace.cost.liteShort": "lite",
"workspace.keys.title": "Clés API",
"workspace.keys.subtitle": "Gérez vos clés API pour accéder aux services OpenCode.",
diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts
index 66a66dc17e..770efde453 100644
--- a/packages/console/app/src/i18n/it.ts
+++ b/packages/console/app/src/i18n/it.ts
@@ -487,7 +487,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(eliminato)",
"workspace.cost.empty": "Nessun dato di utilizzo disponibile per il periodo selezionato.",
"workspace.cost.subscriptionShort": "sub",
- "workspace.cost.liteShort": "lite",
"workspace.keys.title": "Chiavi API",
"workspace.keys.subtitle": "Gestisci le tue chiavi API per accedere ai servizi opencode.",
diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts
index d43105a70a..f2786ba8d8 100644
--- a/packages/console/app/src/i18n/ja.ts
+++ b/packages/console/app/src/i18n/ja.ts
@@ -486,7 +486,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(削除済み)",
"workspace.cost.empty": "選択した期間の使用状況データはありません。",
"workspace.cost.subscriptionShort": "サブ",
- "workspace.cost.liteShort": "lite",
"workspace.keys.title": "APIキー",
"workspace.keys.subtitle": "OpenCodeサービスにアクセスするためのAPIキーを管理します。",
diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts
index c2271e9585..169b56c0a3 100644
--- a/packages/console/app/src/i18n/ko.ts
+++ b/packages/console/app/src/i18n/ko.ts
@@ -480,7 +480,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(삭제됨)",
"workspace.cost.empty": "선택한 기간에 사용 데이터가 없습니다.",
"workspace.cost.subscriptionShort": "구독",
- "workspace.cost.liteShort": "lite",
"workspace.keys.title": "API 키",
"workspace.keys.subtitle": "OpenCode 서비스 액세스를 위한 API 키를 관리하세요.",
diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts
index 9f8585e241..0b6e76e0c3 100644
--- a/packages/console/app/src/i18n/no.ts
+++ b/packages/console/app/src/i18n/no.ts
@@ -485,7 +485,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(slettet)",
"workspace.cost.empty": "Ingen bruksdata tilgjengelig for den valgte perioden.",
"workspace.cost.subscriptionShort": "sub",
- "workspace.cost.liteShort": "lite",
"workspace.keys.title": "API-nøkler",
"workspace.keys.subtitle": "Administrer API-nøklene dine for å få tilgang til opencode-tjenester.",
diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts
index bcc4618a62..b46280ae15 100644
--- a/packages/console/app/src/i18n/pl.ts
+++ b/packages/console/app/src/i18n/pl.ts
@@ -486,7 +486,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(usunięte)",
"workspace.cost.empty": "Brak danych o użyciu dla wybranego okresu.",
"workspace.cost.subscriptionShort": "sub",
- "workspace.cost.liteShort": "lite",
"workspace.keys.title": "Klucze API",
"workspace.keys.subtitle": "Zarządzaj kluczami API do usług opencode.",
diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts
index 5ac9a7ab5f..801c8fc7d4 100644
--- a/packages/console/app/src/i18n/ru.ts
+++ b/packages/console/app/src/i18n/ru.ts
@@ -492,7 +492,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(удалено)",
"workspace.cost.empty": "Нет данных об использовании за выбранный период.",
"workspace.cost.subscriptionShort": "подписка",
- "workspace.cost.liteShort": "lite",
"workspace.keys.title": "API Ключи",
"workspace.keys.subtitle": "Управляйте вашими API ключами для доступа к сервисам opencode.",
diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts
index b442597f18..d9d7d03d1f 100644
--- a/packages/console/app/src/i18n/th.ts
+++ b/packages/console/app/src/i18n/th.ts
@@ -483,7 +483,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(ลบแล้ว)",
"workspace.cost.empty": "ไม่มีข้อมูลการใช้งานในช่วงเวลาที่เลือก",
"workspace.cost.subscriptionShort": "sub",
- "workspace.cost.liteShort": "lite",
"workspace.keys.title": "API Keys",
"workspace.keys.subtitle": "จัดการ API keys ของคุณสำหรับการเข้าถึงบริการ OpenCode",
diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts
index 12e88ca12d..e28afe2b06 100644
--- a/packages/console/app/src/i18n/tr.ts
+++ b/packages/console/app/src/i18n/tr.ts
@@ -488,7 +488,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(silindi)",
"workspace.cost.empty": "Seçilen döneme ait kullanım verisi yok.",
"workspace.cost.subscriptionShort": "abonelik",
- "workspace.cost.liteShort": "lite",
"workspace.keys.title": "API Anahtarları",
"workspace.keys.subtitle": "opencode hizmetlerine erişim için API anahtarlarınızı yönetin.",
diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts
index d358d166ea..87ba1b2450 100644
--- a/packages/console/app/src/i18n/zh.ts
+++ b/packages/console/app/src/i18n/zh.ts
@@ -463,7 +463,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(已删除)",
"workspace.cost.empty": "所选期间无可用使用数据。",
"workspace.cost.subscriptionShort": "订阅",
- "workspace.cost.liteShort": "lite",
"workspace.keys.title": "API 密钥",
"workspace.keys.subtitle": "管理访问 OpenCode 服务的 API 密钥。",
diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts
index 71488405a8..b3f1db0124 100644
--- a/packages/console/app/src/i18n/zht.ts
+++ b/packages/console/app/src/i18n/zht.ts
@@ -464,7 +464,6 @@ export const dict = {
"workspace.cost.deletedSuffix": "(已刪除)",
"workspace.cost.empty": "所選期間沒有可用的使用資料。",
"workspace.cost.subscriptionShort": "訂",
- "workspace.cost.liteShort": "lite",
"workspace.keys.title": "API 金鑰",
"workspace.keys.subtitle": "管理你的 API 金鑰以存取 OpenCode 服務。",
diff --git a/packages/console/app/src/routes/legal/privacy-policy/index.tsx b/packages/console/app/src/routes/legal/privacy-policy/index.tsx
index b1b210455a..42bb71aa39 100644
--- a/packages/console/app/src/routes/legal/privacy-policy/index.tsx
+++ b/packages/console/app/src/routes/legal/privacy-policy/index.tsx
@@ -21,7 +21,7 @@ export default function PrivacyPolicy() {
Privacy Policy
- Effective date: Dec 16, 2025
+ Effective date: Mar 6, 2026
At OpenCode, we take your privacy seriously. Please read this Privacy Policy to learn how we treat your
@@ -30,7 +30,10 @@ export default function PrivacyPolicy() {
By using or accessing our Services in any manner, you acknowledge that you accept the practices and
policies outlined below, and you hereby consent that we will collect, use and disclose your
information as described in this Privacy Policy.
-
+ {" "}
+ For clarity, our open source software that is not provided to you on a hosted basis is subject to the
+ open source license and terms set forth on the applicable repository where you access such open source
+ software, and such license and terms will exclusively govern your use of such open source software.
@@ -382,9 +385,7 @@ export default function PrivacyPolicy() {
Parties You Authorize, Access or Authenticate
-
+ Parties You Authorize, Access or Authenticate.
Legal Obligations
@@ -1502,6 +1503,7 @@ export default function PrivacyPolicy() {
Email: contact@anoma.ly
Phone: +1 415 794-0209
+ Address: 2443 Fillmore St #380-6343, San Francisco, CA 94115, United States
diff --git a/packages/console/app/src/routes/legal/terms-of-service/index.tsx b/packages/console/app/src/routes/legal/terms-of-service/index.tsx
index f770aa7a06..55a9fd42f1 100644
--- a/packages/console/app/src/routes/legal/terms-of-service/index.tsx
+++ b/packages/console/app/src/routes/legal/terms-of-service/index.tsx
@@ -21,12 +21,12 @@ export default function TermsOfService() {
Terms of Use
- Effective date: Dec 16, 2025
+ Effective date: Mar 6, 2026
- Welcome to OpenCode. Please read on to learn the rules and restrictions that govern your use of OpenCode
- (the "Services"). If you have any questions, comments, or concerns regarding these terms or the
- Services, please contact us at:
+ Welcome to OpenCode. Please read on to learn the rules and restrictions that govern your use of
+ OpenCode's website, inference product and hosted software offering (the "Services"). If you have
+ any questions, comments, or concerns regarding these terms or the Services, please contact us at:
@@ -44,7 +44,10 @@ export default function TermsOfService() {
and/or conditions ("Additional Terms"), which are incorporated herein by reference, and you understand
and agree that by using or participating in any such Services, you agree to also comply with these
Additional Terms.
-
+ {" "}
+ For clarity, our open source software that is not provided to you on a hosted basis is subject to the
+ open source license and terms set forth on the applicable repository where you access such open source
+ software, and such license and terms will exclusively govern your use of such open source software.
@@ -460,10 +463,10 @@ export default function TermsOfService() {
Opt-out
You have the right to opt out of the provisions of this Section by sending written notice of your
- decision to opt out to the following address: [ADDRESS], [CITY], Canada [ZIP CODE] postmarked within
- thirty (30) days of first accepting these Terms. You must include (i) your name and residence address,
- (ii) the email address and/or telephone number associated with your account, and (iii) a clear statement
- that you want to opt out of these Terms' arbitration agreement.
+ decision to opt out to the following address: 2443 Fillmore St #380-6343, San Francisco, CA 94115,
+ United States postmarked within thirty (30) days of first accepting these Terms. You must include (i)
+ your name and residence address, (ii) the email address and/or telephone number associated with your
+ account, and (iii) a clear statement that you want to opt out of these Terms' arbitration agreement.
Exclusive Venue
diff --git a/packages/console/app/src/routes/workspace/[id]/graph-section.tsx b/packages/console/app/src/routes/workspace/[id]/graph-section.tsx
index 56a31cdd06..bb4b4f4cfd 100644
--- a/packages/console/app/src/routes/workspace/[id]/graph-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/graph-section.tsx
@@ -218,7 +218,7 @@ export function GraphSection() {
const colorTextSecondary = styles.getPropertyValue("--color-text-secondary").trim()
const colorBorder = styles.getPropertyValue("--color-border").trim()
const subSuffix = ` (${i18n.t("workspace.cost.subscriptionShort")})`
- const liteSuffix = ` (${i18n.t("workspace.cost.liteShort")})`
+ const liteSuffix = " (go)"
const dailyDataRegular = new Map>()
const dailyDataSub = new Map>()
diff --git a/packages/console/core/package.json b/packages/console/core/package.json
index 199b5d9bd6..79de75cfbc 100644
--- a/packages/console/core/package.json
+++ b/packages/console/core/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
- "version": "1.2.19",
+ "version": "1.2.21",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/console/function/package.json b/packages/console/function/package.json
index e771aae844..0e4589cc2c 100644
--- a/packages/console/function/package.json
+++ b/packages/console/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
- "version": "1.2.19",
+ "version": "1.2.21",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json
index ef8d7c5994..4e28f18c0d 100644
--- a/packages/console/mail/package.json
+++ b/packages/console/mail/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
- "version": "1.2.19",
+ "version": "1.2.21",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json
index e683d185bc..7eca0e0417 100644
--- a/packages/desktop-electron/package.json
+++ b/packages/desktop-electron/package.json
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
- "version": "1.2.19",
+ "version": "1.2.21",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
diff --git a/packages/desktop-electron/src/renderer/i18n/index.ts b/packages/desktop-electron/src/renderer/i18n/index.ts
index 81158ad244..be87f94f91 100644
--- a/packages/desktop-electron/src/renderer/i18n/index.ts
+++ b/packages/desktop-electron/src/renderer/i18n/index.ts
@@ -76,6 +76,7 @@ function detectLocale(): Locale {
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
for (const language of languages) {
if (!language) continue
+ if (language.toLowerCase().startsWith("en")) return "en"
if (language.toLowerCase().startsWith("zh")) {
if (language.toLowerCase().includes("hant")) return "zht"
return "zh"
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 10e6df26b1..13b3bfed6b 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
- "version": "1.2.19",
+ "version": "1.2.21",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/desktop/src/i18n/index.ts b/packages/desktop/src/i18n/index.ts
index 7b1ebfe696..e1c1e63d97 100644
--- a/packages/desktop/src/i18n/index.ts
+++ b/packages/desktop/src/i18n/index.ts
@@ -77,6 +77,7 @@ function detectLocale(): Locale {
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
for (const language of languages) {
if (!language) continue
+ if (language.toLowerCase().startsWith("en")) return "en"
if (language.toLowerCase().startsWith("zh")) {
if (language.toLowerCase().includes("hant")) return "zht"
return "zh"
diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json
index ef56add880..0479f42eb4 100644
--- a/packages/enterprise/package.json
+++ b/packages/enterprise/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
- "version": "1.2.19",
+ "version": "1.2.21",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/enterprise/src/core/share.ts b/packages/enterprise/src/core/share.ts
index d7f5c8b8d5..c6291b75d2 100644
--- a/packages/enterprise/src/core/share.ts
+++ b/packages/enterprise/src/core/share.ts
@@ -1,10 +1,8 @@
import { FileDiff, Message, Model, Part, Session } from "@opencode-ai/sdk/v2"
import { fn } from "@opencode-ai/util/fn"
import { iife } from "@opencode-ai/util/iife"
-import { Identifier } from "@opencode-ai/util/identifier"
import z from "zod"
import { Storage } from "./storage"
-import { Binary } from "@opencode-ai/util/binary"
export namespace Share {
export const Info = z.object({
@@ -38,6 +36,81 @@ export namespace Share {
])
export type Data = z.infer
+ type Snapshot = {
+ data: Data[]
+ }
+
+ type Compaction = {
+ event?: string
+ data: Data[]
+ }
+
+ function key(item: Data) {
+ switch (item.type) {
+ case "session":
+ return "session"
+ case "message":
+ return `message/${item.data.id}`
+ case "part":
+ return `part/${item.data.messageID}/${item.data.id}`
+ case "session_diff":
+ return "session_diff"
+ case "model":
+ return "model"
+ }
+ }
+
+ function merge(...items: Data[][]) {
+ const map = new Map()
+ for (const list of items) {
+ for (const item of list) {
+ map.set(key(item), item)
+ }
+ }
+ return Array.from(map.entries())
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([, item]) => item)
+ }
+
+ async function readSnapshot(shareID: string) {
+ return (await Storage.read(["share_snapshot", shareID]))?.data
+ }
+
+ async function writeSnapshot(shareID: string, data: Data[]) {
+ await Storage.write(["share_snapshot", shareID], { data })
+ }
+
+ async function legacy(shareID: string) {
+ const compaction: Compaction = (await Storage.read(["share_compaction", shareID])) ?? {
+ data: [],
+ event: undefined,
+ }
+ const list = await Storage.list({
+ prefix: ["share_event", shareID],
+ before: compaction.event,
+ }).then((x) => x.toReversed())
+ if (list.length === 0) {
+ if (compaction.data.length > 0) await writeSnapshot(shareID, compaction.data)
+ return compaction.data
+ }
+
+ const next = merge(
+ compaction.data,
+ await Promise.all(list.map(async (event) => await Storage.read(event))).then((x) =>
+ x.flatMap((item) => item ?? []),
+ ),
+ )
+
+ await Promise.all([
+ Storage.write(["share_compaction", shareID], {
+ event: list.at(-1)?.at(-1),
+ data: next,
+ }),
+ writeSnapshot(shareID, next),
+ ])
+ return next
+ }
+
export const create = fn(z.object({ sessionID: z.string() }), async (body) => {
const isTest = process.env.NODE_ENV === "test" || body.sessionID.startsWith("test_")
const info: Info = {
@@ -47,7 +120,7 @@ export namespace Share {
}
const exists = await get(info.id)
if (exists) throw new Errors.AlreadyExists(info.id)
- await Storage.write(["share", info.id], info)
+ await Promise.all([Storage.write(["share", info.id], info), writeSnapshot(info.id, [])])
return info
})
@@ -60,8 +133,13 @@ export namespace Share {
if (!share) throw new Errors.NotFound(body.id)
if (share.secret !== body.secret) throw new Errors.InvalidSecret(body.id)
await Storage.remove(["share", body.id])
- const list = await Storage.list({ prefix: ["share_data", body.id] })
- for (const item of list) {
+ const groups = await Promise.all([
+ Storage.list({ prefix: ["share_snapshot", body.id] }),
+ Storage.list({ prefix: ["share_compaction", body.id] }),
+ Storage.list({ prefix: ["share_event", body.id] }),
+ Storage.list({ prefix: ["share_data", body.id] }),
+ ])
+ for (const item of groups.flat()) {
await Storage.remove(item)
}
})
@@ -75,59 +153,13 @@ export namespace Share {
const share = await get(input.share.id)
if (!share) throw new Errors.NotFound(input.share.id)
if (share.secret !== input.share.secret) throw new Errors.InvalidSecret(input.share.id)
- await Storage.write(["share_event", input.share.id, Identifier.descending()], input.data)
+ const data = (await readSnapshot(input.share.id)) ?? (await legacy(input.share.id))
+ await writeSnapshot(input.share.id, merge(data, input.data))
},
)
- type Compaction = {
- event?: string
- data: Data[]
- }
-
export async function data(shareID: string) {
- console.log("reading compaction")
- const compaction: Compaction = (await Storage.read(["share_compaction", shareID])) ?? {
- data: [],
- event: undefined,
- }
- console.log("reading pending events")
- const list = await Storage.list({
- prefix: ["share_event", shareID],
- before: compaction.event,
- }).then((x) => x.toReversed())
-
- console.log("compacting", list.length)
-
- if (list.length > 0) {
- const data = await Promise.all(list.map(async (event) => await Storage.read(event))).then((x) => x.flat())
- for (const item of data) {
- if (!item) continue
- const key = (item: Data) => {
- switch (item.type) {
- case "session":
- return "session"
- case "message":
- return `message/${item.data.id}`
- case "part":
- return `${item.data.messageID}/${item.data.id}`
- case "session_diff":
- return "session_diff"
- case "model":
- return "model"
- }
- }
- const id = key(item)
- const result = Binary.search(compaction.data, id, key)
- if (result.found) {
- compaction.data[result.index] = item
- } else {
- compaction.data.splice(result.index, 0, item)
- }
- }
- compaction.event = list.at(-1)?.at(-1)
- await Storage.write(["share_compaction", shareID], compaction)
- }
- return compaction.data
+ return (await readSnapshot(shareID)) ?? legacy(shareID)
}
export const syncOld = fn(
diff --git a/packages/enterprise/src/routes/api/[...path].ts b/packages/enterprise/src/routes/api/[...path].ts
index e77c00de92..f97788bd03 100644
--- a/packages/enterprise/src/routes/api/[...path].ts
+++ b/packages/enterprise/src/routes/api/[...path].ts
@@ -108,6 +108,7 @@ app
validator("param", z.object({ shareID: z.string() })),
async (c) => {
const { shareID } = c.req.valid("param")
+ c.header("Cache-Control", "public, max-age=30, s-maxage=300, stale-while-revalidate=86400")
return c.json(await Share.data(shareID))
},
)
diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx
index 007b4c268d..e755ea75a1 100644
--- a/packages/enterprise/src/routes/share/[shareID].tsx
+++ b/packages/enterprise/src/routes/share/[shareID].tsx
@@ -5,12 +5,11 @@ import { DataProvider } from "@opencode-ai/ui/context"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
import { createAsync, query, useParams } from "@solidjs/router"
-import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
+import { createMemo, createSignal, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
import { Share } from "~/core/share"
import { Logo, Mark } from "@opencode-ai/ui/logo"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import { createDefaultOptions } from "@opencode-ai/ui/pierre"
import { iife } from "@opencode-ai/util/iife"
import { Binary } from "@opencode-ai/util/binary"
import { NamedError } from "@opencode-ai/util/error"
@@ -20,11 +19,11 @@ import z from "zod"
import NotFound from "../[...404]"
import { Tabs } from "@opencode-ai/ui/tabs"
import { MessageNav } from "@opencode-ai/ui/message-nav"
-import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { FileSSR } from "@opencode-ai/ui/file-ssr"
import { clientOnly } from "@solidjs/start"
import { Meta, Title } from "@solidjs/meta"
import { Base64 } from "js-base64"
+import { getRequestEvent } from "solid-js/web"
const ClientOnlyWorkerPoolProvider = clientOnly(() =>
import("@opencode-ai/ui/pierre/worker").then((m) => ({
@@ -54,12 +53,6 @@ const getData = query(async (shareID) => {
session_diff: {
[sessionID: string]: FileDiff[]
}
- session_diff_preload: {
- [sessionID: string]: PreloadMultiFileDiffResult[]
- }
- session_diff_preload_split: {
- [sessionID: string]: PreloadMultiFileDiffResult[]
- }
session_status: {
[sessionID: string]: SessionStatus
}
@@ -79,12 +72,6 @@ const getData = query(async (shareID) => {
session_diff: {
[share.sessionID]: [],
},
- session_diff_preload: {
- [share.sessionID]: [],
- },
- session_diff_preload_split: {
- [share.sessionID]: [],
- },
session_status: {
[share.sessionID]: {
type: "idle",
@@ -101,28 +88,6 @@ const getData = query(async (shareID) => {
break
case "session_diff":
result.session_diff[share.sessionID] = item.data
- await Promise.all([
- Promise.all(
- item.data.map(async (diff) =>
- preloadMultiFileDiff({
- oldFile: { name: diff.file, contents: diff.before },
- newFile: { name: diff.file, contents: diff.after },
- options: createDefaultOptions("unified"),
- // annotations,
- }),
- ),
- ).then((r) => (result.session_diff_preload[share.sessionID] = r)),
- Promise.all(
- item.data.map(async (diff) =>
- preloadMultiFileDiff({
- oldFile: { name: diff.file, contents: diff.before },
- newFile: { name: diff.file, contents: diff.after },
- options: createDefaultOptions("split"),
- // annotations,
- }),
- ),
- ).then((r) => (result.session_diff_preload_split[share.sessionID] = r)),
- ])
break
case "message":
result.message[item.data.sessionID] = result.message[item.data.sessionID] ?? []
@@ -143,17 +108,15 @@ const getData = query(async (shareID) => {
}, "getShareData")
export default function () {
+ getRequestEvent()?.response.headers.set(
+ "Cache-Control",
+ "public, max-age=30, s-maxage=300, stale-while-revalidate=86400",
+ )
+
const params = useParams()
const data = createAsync(async () => {
if (!params.shareID) throw new Error("Missing shareID")
- const now = Date.now()
- const data = getData(params.shareID)
- console.log("getData", Date.now() - now)
- return data
- })
-
- createEffect(() => {
- console.log(data())
+ return getData(params.shareID)
})
return (
@@ -241,22 +204,8 @@ export default function () {
const provider = createMemo(() => activeMessage()?.model?.providerID)
const modelID = createMemo(() => activeMessage()?.model?.modelID)
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
- const diffs = createMemo(() => {
- const diffs = data().session_diff[data().sessionID] ?? []
- const preloaded = data().session_diff_preload[data().sessionID] ?? []
- return diffs.map((diff) => ({
- ...diff,
- preloaded: preloaded.find((d) => d.newFile.name === diff.file),
- }))
- })
- const splitDiffs = createMemo(() => {
- const diffs = data().session_diff[data().sessionID] ?? []
- const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
- return diffs.map((diff) => ({
- ...diff,
- preloaded: preloaded.find((d) => d.newFile.name === diff.file),
- }))
- })
+ const diffs = createMemo(() => data().session_diff[data().sessionID] ?? [])
+ const [diffStyle, setDiffStyle] = createSignal<"unified" | "split">("unified")
const title = () => (
@@ -380,18 +329,9 @@ export default function () {
0}>
-
{turns()}
-
+
+ }
+ >
+ {(onOpenChange) => (
+
+
+
+
+
+ )}
+
+ )
+ }
+
+ const [, rest] = splitProps(props, ["variant"])
+ return
+}
+export const ToolCall = ToolCallRoot
+
export function GenericTool(props: {
tool: string
status?: string
@@ -229,7 +391,8 @@ export function GenericTool(props: {
input?: Record
}) {
return (
-
)
}
diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css
index bab2c4f926..1a86338bdc 100644
--- a/packages/ui/src/components/collapsible.css
+++ b/packages/ui/src/components/collapsible.css
@@ -8,14 +8,18 @@
border-radius: var(--radius-md);
overflow: visible;
- &.tool-collapsible {
- gap: 8px;
+ &.tool-collapsible [data-slot="collapsible-trigger"] {
+ height: 37px;
+ }
+
+ &.tool-collapsible [data-slot="basic-tool-content-inner"] {
+ padding-top: 0;
}
[data-slot="collapsible-trigger"] {
width: 100%;
display: flex;
- height: 32px;
+ height: 36px;
padding: 0;
align-items: center;
align-self: stretch;
@@ -23,6 +27,17 @@
user-select: none;
color: var(--text-base);
+ > [data-component="tool-trigger"][data-arrow] {
+ width: auto;
+ max-width: 100%;
+ flex: 0 1 auto;
+
+ [data-slot="basic-tool-tool-trigger-content"] {
+ width: auto;
+ max-width: 100%;
+ }
+ }
+
[data-slot="collapsible-arrow"] {
opacity: 0;
transition: opacity 0.15s ease;
@@ -50,9 +65,6 @@
line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal);
- /* &:hover { */
- /* background-color: var(--surface-base); */
- /* } */
&:focus-visible {
outline: none;
background-color: var(--surface-raised-base-hover);
@@ -82,16 +94,16 @@
}
[data-slot="collapsible-content"] {
- overflow: hidden;
- /* animation: slideUp 250ms ease-out; */
+ overflow: clip;
&[data-expanded] {
overflow: visible;
}
- /* &[data-expanded] { */
- /* animation: slideDown 250ms ease-out; */
- /* } */
+ /* JS-animated content: overflow managed by animate() */
+ &[data-spring-content] {
+ overflow: clip;
+ }
}
&[data-variant="ghost"] {
@@ -103,9 +115,6 @@
border: none;
padding: 0;
- /* &:hover { */
- /* color: var(--text-strong); */
- /* } */
&:focus-visible {
outline: none;
background-color: var(--surface-raised-base-hover);
@@ -122,21 +131,3 @@
}
}
}
-
-@keyframes slideDown {
- from {
- height: 0;
- }
- to {
- height: var(--kb-collapsible-content-height);
- }
-}
-
-@keyframes slideUp {
- from {
- height: var(--kb-collapsible-content-height);
- }
- to {
- height: 0;
- }
-}
diff --git a/packages/ui/src/components/context-tool-results.tsx b/packages/ui/src/components/context-tool-results.tsx
new file mode 100644
index 0000000000..25d120e05e
--- /dev/null
+++ b/packages/ui/src/components/context-tool-results.tsx
@@ -0,0 +1,199 @@
+import { createMemo, createSignal, For, onMount } from "solid-js"
+import type { ToolPart } from "@opencode-ai/sdk/v2"
+import { getFilename } from "@opencode-ai/util/path"
+import { useI18n } from "../context/i18n"
+import { prefersReducedMotion } from "../hooks/use-reduced-motion"
+import { ToolCall } from "./basic-tool"
+import { ToolStatusTitle } from "./tool-status-title"
+import { AnimatedCountList } from "./tool-count-summary"
+import { RollingResults } from "./rolling-results"
+import { GROW_SPRING } from "./motion"
+import { useSpring } from "./motion-spring"
+import { busy, updateScrollMask, useCollapsible, useRowWipe } from "./tool-utils"
+
+function contextToolLabel(part: ToolPart): { action: string; detail: string } {
+ const state = part.state
+ const title = "title" in state ? (state.title as string | undefined) : undefined
+ const input = state.input
+ if (part.tool === "read") {
+ const path = input?.filePath as string | undefined
+ return { action: "Read", detail: title || (path ? getFilename(path) : "") }
+ }
+ if (part.tool === "grep") {
+ const pattern = input?.pattern as string | undefined
+ return { action: "Search", detail: title || (pattern ? `"${pattern}"` : "") }
+ }
+ if (part.tool === "glob") {
+ const pattern = input?.pattern as string | undefined
+ return { action: "Find", detail: title || (pattern ?? "") }
+ }
+ if (part.tool === "list") {
+ const path = input?.path as string | undefined
+ return { action: "List", detail: title || (path ? getFilename(path) : "") }
+ }
+ return { action: part.tool, detail: title || "" }
+}
+
+function contextToolSummary(parts: ToolPart[]) {
+ let read = 0
+ let search = 0
+ let list = 0
+ for (const part of parts) {
+ if (part.tool === "read") read++
+ else if (part.tool === "glob" || part.tool === "grep") search++
+ else if (part.tool === "list") list++
+ }
+ return { read, search, list }
+}
+
+export function ContextToolGroupHeader(props: {
+ parts: ToolPart[]
+ pending: boolean
+ open: boolean
+ onOpenChange: (value: boolean) => void
+}) {
+ const i18n = useI18n()
+ const summary = createMemo(() => contextToolSummary(props.parts))
+ return (
+ {
+ if (!props.pending) props.onOpenChange(v)
+ }}
+ trigger={
+
+ }
+ />
+ )
+}
+
+export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: boolean }) {
+ let contentRef: HTMLDivElement | undefined
+ let bodyRef: HTMLDivElement | undefined
+ let scrollRef: HTMLDivElement | undefined
+ const updateMask = () => {
+ if (scrollRef) updateScrollMask(scrollRef)
+ }
+
+ useCollapsible({
+ content: () => contentRef,
+ body: () => bodyRef,
+ open: () => props.expanded,
+ onOpen: updateMask,
+ })
+
+ return (
+
+
+
+
+ {(part) => {
+ const label = createMemo(() => contextToolLabel(part))
+ return (
+
+ {label().action}
+ {label().detail}
+
+ )
+ }}
+
+
+
+
+ )
+}
+
+export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) {
+ const wiped = new Set()
+ const [mounted, setMounted] = createSignal(false)
+ onMount(() => setMounted(true))
+ const reduce = prefersReducedMotion
+ const show = () => mounted() && props.pending
+ const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING)
+ const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING)
+ return (
+
+
part.callID || part.id}
+ render={(part) => {
+ const label = createMemo(() => contextToolLabel(part))
+ const k = part.callID || part.id
+ return (
+
+ {label().action}
+ {(() => {
+ const [detailRef, setDetailRef] = createSignal()
+ useRowWipe({
+ id: () => k,
+ text: () => label().detail,
+ ref: detailRef,
+ seen: wiped,
+ })
+ return (
+
+ {label().detail}
+
+ )
+ })()}
+
+ )
+ }}
+ />
+
+ )
+}
diff --git a/packages/ui/src/components/grow-box.tsx b/packages/ui/src/components/grow-box.tsx
new file mode 100644
index 0000000000..ec4921ab3a
--- /dev/null
+++ b/packages/ui/src/components/grow-box.tsx
@@ -0,0 +1,426 @@
+import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js"
+import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion"
+import { prefersReducedMotion } from "../hooks/use-reduced-motion"
+
+export interface GrowBoxProps {
+ children: JSX.Element
+ /** Enable animation. When false, content shows immediately at full height. */
+ animate?: boolean
+ /** Animate height from 0 to content height. Default: true. */
+ grow?: boolean
+ /** Keep watching body size and animate subsequent height changes. Default: false. */
+ watch?: boolean
+ /** Fade in body content (opacity + blur). Default: true. */
+ fade?: boolean
+ /** Top padding in px on the body wrapper. Default: 0. */
+ gap?: number
+ /** Reset to height:auto after grow completes, or stay at fixed px. Default: true. */
+ autoHeight?: boolean
+ /** Controlled visibility for animating open/close without unmounting children. */
+ open?: boolean
+ /** Animate controlled open/close changes after mount. Default: true. */
+ animateToggle?: boolean
+ /** data-slot attribute on the root div. */
+ slot?: string
+ /** CSS class on the root div. */
+ class?: string
+ /** Override mount and resize spring config. Default: GROW_SPRING. */
+ spring?: SpringConfig
+ /** Override controlled open/close spring config. Default: spring. */
+ toggleSpring?: SpringConfig
+ /** Show a temporary bottom edge fade while height animation is running. */
+ edge?: boolean
+ /** Edge fade height in px. Default: 20. */
+ edgeHeight?: number
+ /** Edge fade opacity (0-1). Default: 1. */
+ edgeOpacity?: number
+ /** Delay before edge fades out after height settles. Default: 320. */
+ edgeIdle?: number
+ /** Edge fade-out duration in seconds. Default: 0.24. */
+ edgeFade?: number
+ /** Edge fade-in duration in seconds. Default: 0.2. */
+ edgeRise?: number
+}
+
+/**
+ * Wraps children in a container that animates from zero height on mount.
+ *
+ * Includes a ResizeObserver so content changes after mount are also spring-animated.
+ * Used for timeline turns, assistant part groups, and user messages.
+ */
+export function GrowBox(props: GrowBoxProps) {
+ const reduce = prefersReducedMotion
+ const spring = () => props.spring ?? GROW_SPRING
+ const toggleSpring = () => props.toggleSpring ?? spring()
+ let mode: "mount" | "toggle" = "mount"
+ let root: HTMLDivElement | undefined
+ let body: HTMLDivElement | undefined
+ let fadeAnim: AnimationPlaybackControls | undefined
+ let edgeRef: HTMLDivElement | undefined
+ let edgeAnim: AnimationPlaybackControls | undefined
+ let edgeTimer: ReturnType | undefined
+ let edgeOn = false
+ let mountFrame: number | undefined
+ let resizeFrame: number | undefined
+ let observer: ResizeObserver | undefined
+ let springTarget = -1
+ const height = tunableSpringValue(0, {
+ type: "spring",
+ get visualDuration() {
+ return (mode === "toggle" ? toggleSpring() : spring()).visualDuration
+ },
+ get bounce() {
+ return (mode === "toggle" ? toggleSpring() : spring()).bounce
+ },
+ })
+
+ const gap = () => Math.max(0, props.gap ?? 0)
+ const grow = () => props.grow !== false
+ const watch = () => props.watch === true
+ const open = () => props.open !== false
+ const animateToggle = () => props.animateToggle !== false
+ const edge = () => props.edge === true
+ const edgeHeight = () => Math.max(0, props.edgeHeight ?? 20)
+ const edgeOpacity = () => Math.min(1, Math.max(0, props.edgeOpacity ?? 1))
+ const edgeIdle = () => Math.max(0, props.edgeIdle ?? 320)
+ const edgeFade = () => Math.max(0.05, props.edgeFade ?? 0.24)
+ const edgeRise = () => Math.max(0.05, props.edgeRise ?? 0.2)
+ const animated = () => props.animate !== false && !reduce()
+ const edgeReady = () => animated() && open() && edge() && edgeHeight() > 0
+
+ const stopEdgeTimer = () => {
+ if (edgeTimer === undefined) return
+ clearTimeout(edgeTimer)
+ edgeTimer = undefined
+ }
+
+ const hideEdge = (instant = false) => {
+ stopEdgeTimer()
+ if (!edgeRef) {
+ edgeOn = false
+ return
+ }
+ edgeAnim?.stop()
+ edgeAnim = undefined
+ if (instant || reduce()) {
+ edgeRef.style.opacity = "0"
+ edgeOn = false
+ return
+ }
+ if (!edgeOn) {
+ edgeRef.style.opacity = "0"
+ return
+ }
+ const current = animate(edgeRef, { opacity: 0 }, { type: "spring", visualDuration: edgeFade(), bounce: 0 })
+ edgeAnim = current
+ current.finished
+ .catch(() => {})
+ .finally(() => {
+ if (edgeAnim !== current) return
+ edgeAnim = undefined
+ if (!edgeRef) return
+ edgeRef.style.opacity = "0"
+ edgeOn = false
+ })
+ }
+
+ const showEdge = () => {
+ stopEdgeTimer()
+ if (!edgeRef) return
+ if (reduce()) {
+ edgeRef.style.opacity = `${edgeOpacity()}`
+ edgeOn = true
+ return
+ }
+ if (edgeOn && edgeAnim === undefined) {
+ edgeRef.style.opacity = `${edgeOpacity()}`
+ return
+ }
+ edgeAnim?.stop()
+ edgeAnim = undefined
+ if (!edgeOn) edgeRef.style.opacity = "0"
+ const current = animate(
+ edgeRef,
+ { opacity: edgeOpacity() },
+ { type: "spring", visualDuration: edgeRise(), bounce: 0 },
+ )
+ edgeAnim = current
+ edgeOn = true
+ current.finished
+ .catch(() => {})
+ .finally(() => {
+ if (edgeAnim !== current) return
+ edgeAnim = undefined
+ if (!edgeRef) return
+ edgeRef.style.opacity = `${edgeOpacity()}`
+ })
+ }
+
+ const queueEdgeHide = () => {
+ stopEdgeTimer()
+ if (!edgeOn) return
+ if (edgeIdle() <= 0) {
+ hideEdge()
+ return
+ }
+ edgeTimer = setTimeout(() => {
+ edgeTimer = undefined
+ hideEdge()
+ }, edgeIdle())
+ }
+
+ const hideBody = () => {
+ if (!body) return
+ body.style.opacity = "0"
+ body.style.filter = "blur(2px)"
+ }
+
+ const clearBody = () => {
+ if (!body) return
+ body.style.opacity = ""
+ body.style.filter = ""
+ }
+
+ const fadeBodyIn = (nextMode: "mount" | "toggle" = "mount") => {
+ if (props.fade === false || !body) return
+ if (reduce()) {
+ clearBody()
+ return
+ }
+ hideBody()
+ fadeAnim?.stop()
+ fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, nextMode === "toggle" ? toggleSpring() : spring())
+ fadeAnim.finished.then(() => {
+ if (!body || !open()) return
+ clearBody()
+ })
+ }
+
+ const setInstant = (visible: boolean) => {
+ const next = visible ? targetHeight() : 0
+ springTarget = next
+ height.jump(next)
+ root!.style.height = visible ? "" : "0px"
+ root!.style.overflow = visible ? "" : "clip"
+ hideEdge(true)
+ if (visible || props.fade === false) clearBody()
+ else hideBody()
+ }
+
+ const currentHeight = () => {
+ if (!root) return 0
+ const v = root.style.height
+ if (v && v !== "auto") {
+ const n = Number.parseFloat(v)
+ if (!Number.isNaN(n)) return n
+ }
+ return Math.max(0, root.getBoundingClientRect().height)
+ }
+
+ const targetHeight = () => Math.max(0, Math.ceil(body?.getBoundingClientRect().height ?? 0))
+
+ const setHeight = (nextMode: "mount" | "toggle" = "mount") => {
+ if (!root || !open()) return
+ const next = targetHeight()
+ if (reduce()) {
+ springTarget = next
+ height.jump(next)
+ if (props.autoHeight === false || watch()) {
+ root.style.height = `${next}px`
+ root.style.overflow = next > 0 ? "visible" : "clip"
+ return
+ }
+ root.style.height = "auto"
+ root.style.overflow = next > 0 ? "visible" : "clip"
+ return
+ }
+ if (next === springTarget) return
+ const prev = currentHeight()
+ if (Math.abs(next - prev) < 1) {
+ springTarget = next
+ if (props.autoHeight === false || watch()) {
+ root.style.height = `${next}px`
+ root.style.overflow = next > 0 ? "visible" : "clip"
+ }
+ return
+ }
+ root.style.overflow = "clip"
+ springTarget = next
+ mode = nextMode
+ height.set(next)
+ }
+
+ onMount(() => {
+ if (!root || !body) return
+
+ const offChange = height.on("change", (next) => {
+ if (!root) return
+ root.style.height = `${Math.max(0, next)}px`
+ })
+ const offStart = height.on("animationStart", () => {
+ if (!root) return
+ root.style.overflow = "clip"
+ root.style.willChange = "height"
+ root.style.contain = "layout style"
+ if (edgeReady()) showEdge()
+ })
+ const offComplete = height.on("animationComplete", () => {
+ if (!root) return
+ root.style.willChange = ""
+ root.style.contain = ""
+ if (!open()) {
+ springTarget = 0
+ root.style.height = "0px"
+ root.style.overflow = "clip"
+ return
+ }
+ const next = targetHeight()
+ springTarget = next
+ if (props.autoHeight === false || watch()) {
+ root.style.height = `${next}px`
+ root.style.overflow = next > 0 ? "visible" : "clip"
+ if (edgeReady()) queueEdgeHide()
+ return
+ }
+ root.style.height = "auto"
+ root.style.overflow = "visible"
+ if (edgeReady()) queueEdgeHide()
+ })
+
+ onCleanup(() => {
+ offComplete()
+ offStart()
+ offChange()
+ })
+
+ if (!animated()) {
+ setInstant(open())
+ return
+ }
+
+ if (props.fade !== false) hideBody()
+ hideEdge(true)
+
+ if (!open()) {
+ root.style.height = "0px"
+ root.style.overflow = "clip"
+ } else {
+ if (grow()) {
+ root.style.height = "0px"
+ root.style.overflow = "clip"
+ } else {
+ root.style.height = "auto"
+ root.style.overflow = "visible"
+ }
+ mountFrame = requestAnimationFrame(() => {
+ mountFrame = undefined
+ fadeBodyIn("mount")
+ if (grow()) setHeight("mount")
+ })
+ }
+ if (watch()) {
+ observer = new ResizeObserver(() => {
+ if (!open()) return
+ if (resizeFrame !== undefined) return
+ resizeFrame = requestAnimationFrame(() => {
+ resizeFrame = undefined
+ setHeight("mount")
+ })
+ })
+ observer.observe(body)
+ }
+ })
+
+ createEffect(
+ on(
+ () => props.open,
+ (value) => {
+ if (value === undefined) return
+ if (!root || !body) return
+ if (!animateToggle() || reduce()) {
+ setInstant(value)
+ return
+ }
+ fadeAnim?.stop()
+ if (!value) hideEdge(true)
+ if (!value) {
+ const next = currentHeight()
+ if (Math.abs(next - height.get()) >= 1) {
+ springTarget = next
+ height.jump(next)
+ root.style.height = `${next}px`
+ }
+ if (props.fade !== false) {
+ fadeAnim = animate(body, { opacity: 0, filter: "blur(2px)" }, toggleSpring())
+ }
+ root.style.overflow = "clip"
+ springTarget = 0
+ mode = "toggle"
+ height.set(0)
+ return
+ }
+ fadeBodyIn("toggle")
+ setHeight("toggle")
+ },
+ { defer: true },
+ ),
+ )
+
+ createEffect(() => {
+ if (!edgeRef) return
+ edgeRef.style.height = `${edgeHeight()}px`
+ if (!animated() || !open() || edgeHeight() <= 0) {
+ hideEdge(true)
+ return
+ }
+ if (edge()) return
+ hideEdge()
+ })
+
+ createEffect(() => {
+ if (!root || !body) return
+ if (!reduce()) return
+ fadeAnim?.stop()
+ edgeAnim?.stop()
+ setInstant(open())
+ })
+
+ onCleanup(() => {
+ stopEdgeTimer()
+ if (mountFrame !== undefined) cancelAnimationFrame(mountFrame)
+ if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
+ observer?.disconnect()
+ height.destroy()
+ fadeAnim?.stop()
+ edgeAnim?.stop()
+ edgeAnim = undefined
+ edgeOn = false
+ })
+
+ return (
+
+
0 ? `${gap()}px` : undefined }}>
+ {props.children}
+
+
+
+ )
+}
diff --git a/packages/ui/src/components/line-comment.tsx b/packages/ui/src/components/line-comment.tsx
index 6a247990b3..73d83f7d72 100644
--- a/packages/ui/src/components/line-comment.tsx
+++ b/packages/ui/src/components/line-comment.tsx
@@ -240,6 +240,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
}}
on:keydown={(e) => {
const event = e as KeyboardEvent
+ if (event.isComposing || event.keyCode === 229) return
event.stopPropagation()
if (e.key === "Escape") {
event.preventDefault()
diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx
index bb41c74efb..01254f1189 100644
--- a/packages/ui/src/components/markdown.tsx
+++ b/packages/ui/src/components/markdown.tsx
@@ -44,6 +44,19 @@ function sanitize(html: string) {
return DOMPurify.sanitize(html, config)
}
+function escape(text: string) {
+ return text
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/\"/g, """)
+ .replace(/'/g, "'")
+}
+
+function fallback(markdown: string) {
+ return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "
")
+}
+
type CopyLabels = {
copy: string
copied: string
@@ -237,7 +250,7 @@ export function Markdown(
const [html] = createResource(
() => local.text,
async (markdown) => {
- if (isServer) return ""
+ if (isServer) return fallback(markdown)
const hash = checksum(markdown)
const key = local.cacheKey ?? hash
@@ -255,7 +268,7 @@ export function Markdown(
if (key && hash) touch(key, { hash, html: safe })
return safe
},
- { initialValue: "" },
+ { initialValue: isServer ? fallback(local.text) : "" },
)
let copySetupTimer: ReturnType | undefined
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index 8fc7090133..9a6784d702 100644
--- a/packages/ui/src/components/message-part.css
+++ b/packages/ui/src/components/message-part.css
@@ -1,10 +1,20 @@
[data-component="assistant-message"] {
content-visibility: auto;
width: 100%;
+}
+
+[data-component="assistant-parts"] {
+ width: 100%;
+ min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
- gap: 12px;
+ gap: 0;
+}
+
+[data-component="assistant-part-item"] {
+ width: 100%;
+ min-width: 0;
}
[data-component="user-message"] {
@@ -27,6 +37,14 @@
color: var(--text-weak);
}
+ [data-slot="user-message-inner"] {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ width: 100%;
+ gap: 4px;
+ }
[data-slot="user-message-attachments"] {
display: flex;
flex-wrap: wrap;
@@ -35,6 +53,7 @@
width: fit-content;
max-width: min(82%, 64ch);
margin-left: auto;
+ margin-bottom: 4px;
}
[data-slot="user-message-attachment"] {
@@ -134,7 +153,7 @@
[data-slot="user-message-copy-wrapper"] {
min-height: 24px;
- margin-top: 4px;
+ margin-top: 0;
display: flex;
align-items: center;
justify-content: flex-end;
@@ -144,7 +163,6 @@
pointer-events: none;
transition: opacity 0.15s ease;
will-change: opacity;
-
[data-component="tooltip-trigger"] {
display: inline-flex;
width: fit-content;
@@ -187,56 +205,21 @@
opacity: 1;
pointer-events: auto;
}
-
- .text-text-strong {
- color: var(--text-strong);
- }
-
- .font-medium {
- font-weight: var(--font-weight-medium);
- }
}
[data-component="text-part"] {
width: 100%;
- margin-top: 24px;
+ margin-top: 0;
+ padding-block: 4px;
+ position: relative;
[data-slot="text-part-body"] {
margin-top: 0;
}
- [data-slot="text-part-copy-wrapper"] {
- min-height: 24px;
- margin-top: 4px;
- display: flex;
- align-items: center;
- justify-content: flex-start;
- gap: 10px;
- opacity: 0;
- pointer-events: none;
- transition: opacity 0.15s ease;
- will-change: opacity;
-
- [data-component="tooltip-trigger"] {
- display: inline-flex;
- width: fit-content;
- }
- }
-
- [data-slot="text-part-meta"] {
- user-select: none;
- }
-
- [data-slot="text-part-copy-wrapper"][data-interrupted] {
+ [data-slot="text-part-turn-summary"] {
width: 100%;
- justify-content: flex-end;
- gap: 12px;
- }
-
- &:hover [data-slot="text-part-copy-wrapper"],
- &:focus-within [data-slot="text-part-copy-wrapper"] {
- opacity: 1;
- pointer-events: auto;
+ min-width: 0;
}
[data-component="markdown"] {
@@ -245,6 +228,10 @@
}
}
+[data-component="assistant-part-item"][data-kind="text"][data-last="true"] [data-component="text-part"] {
+ padding-bottom: 0;
+}
+
[data-component="compaction-part"] {
width: 100%;
display: flex;
@@ -278,7 +265,6 @@
line-height: var(--line-height-normal);
[data-component="markdown"] {
- margin-top: 24px;
font-style: normal;
font-size: inherit;
color: var(--text-weak);
@@ -372,13 +358,16 @@
height: auto;
max-height: 240px;
overflow-y: auto;
+ overscroll-behavior: contain;
scrollbar-width: none;
-ms-overflow-style: none;
-
+ -webkit-mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%);
+ mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%);
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
&::-webkit-scrollbar {
display: none;
}
-
[data-component="markdown"] {
overflow: visible;
}
@@ -448,7 +437,7 @@
[data-component="write-trigger"] {
display: flex;
align-items: center;
- justify-content: space-between;
+ justify-content: flex-start;
gap: 8px;
width: 100%;
@@ -461,7 +450,8 @@
}
[data-slot="message-part-title"] {
- flex-shrink: 0;
+ flex-shrink: 1;
+ min-width: 0;
display: flex;
align-items: center;
gap: 8px;
@@ -493,40 +483,45 @@
[data-slot="message-part-title-text"] {
text-transform: capitalize;
color: var(--text-strong);
+ flex-shrink: 0;
+ }
+
+ [data-slot="message-part-meta-line"],
+ .message-part-meta-line {
+ min-width: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-weight: var(--font-weight-regular);
+
+ [data-component="diff-changes"] {
+ flex-shrink: 0;
+ gap: 6px;
+ }
+ }
+
+ .message-part-meta-line.soft {
+ [data-slot="message-part-title-filename"] {
+ color: var(--text-base);
+ }
}
[data-slot="message-part-title-filename"] {
/* No text-transform - preserve original filename casing */
- font-weight: var(--font-weight-regular);
+ color: var(--text-strong);
+ flex-shrink: 0;
}
- [data-slot="message-part-path"] {
- display: flex;
- flex-grow: 1;
- min-width: 0;
- font-weight: var(--font-weight-regular);
- }
-
- [data-slot="message-part-directory"] {
+ [data-slot="message-part-directory-inline"] {
color: var(--text-weak);
+ min-width: 0;
+ max-width: min(48vw, 36ch);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
direction: rtl;
text-align: left;
}
-
- [data-slot="message-part-filename"] {
- color: var(--text-strong);
- flex-shrink: 0;
- }
-
- [data-slot="message-part-actions"] {
- display: flex;
- gap: 16px;
- align-items: center;
- justify-content: flex-end;
- }
}
[data-component="edit-content"] {
@@ -617,6 +612,17 @@
}
}
+[data-slot="webfetch-meta"] {
+ min-width: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+
+ [data-component="tool-action"] {
+ flex-shrink: 0;
+ }
+}
+
[data-component="todos"] {
padding: 10px 0 24px 0;
display: flex;
@@ -639,7 +645,6 @@
}
[data-component="context-tool-group-trigger"] {
- width: 100%;
min-height: 24px;
display: flex;
align-items: center;
@@ -647,28 +652,352 @@
gap: 0px;
cursor: pointer;
+ &[data-pending] {
+ cursor: default;
+ }
+
[data-slot="context-tool-group-title"] {
flex-shrink: 1;
min-width: 0;
}
+}
- [data-slot="collapsible-arrow"] {
- color: var(--icon-weaker);
+/* Prevent the trigger content from stretching full-width so the arrow sits after the text */
+[data-slot="basic-tool-tool-trigger-content"]:has([data-component="context-tool-group-trigger"]) {
+ width: auto;
+ flex: 0 1 auto;
+
+ [data-slot="basic-tool-tool-info"] {
+ flex: 0 1 auto;
}
}
-[data-component="context-tool-group-list"] {
- padding: 6px 0 4px 0;
+[data-component="context-tool-step"] {
+ width: 100%;
+ min-width: 0;
+ padding-left: 12px;
+}
+
+[data-component="context-tool-expanded-list"] {
display: flex;
flex-direction: column;
- gap: 2px;
+ padding: 4px 0 4px 12px;
+ max-height: 200px;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
- [data-slot="context-tool-group-item"] {
- min-width: 0;
- padding: 6px 0;
+ &::-webkit-scrollbar {
+ display: none;
}
}
+[data-component="context-tool-expanded-row"] {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ height: 22px;
+ flex-shrink: 0;
+ white-space: nowrap;
+ overflow: hidden;
+
+ [data-slot="context-tool-expanded-action"] {
+ flex-shrink: 0;
+ font-size: var(--font-size-base);
+ font-weight: 500;
+ color: var(--text-base);
+ }
+
+ [data-slot="context-tool-expanded-detail"] {
+ flex-shrink: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: var(--font-size-base);
+ color: var(--text-base);
+ opacity: 0.75;
+ }
+}
+
+[data-component="context-tool-rolling-row"] {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ width: 100%;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ padding-left: 12px;
+
+ [data-slot="context-tool-rolling-action"] {
+ flex-shrink: 0;
+ font-size: var(--font-size-base);
+ font-weight: 500;
+ color: var(--text-base);
+ }
+
+ [data-slot="context-tool-rolling-detail"] {
+ flex-shrink: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: var(--font-size-base);
+ color: var(--text-weak);
+ }
+}
+
+[data-component="shell-rolling-results"] {
+ width: 100%;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+
+ [data-slot="shell-rolling-header-clip"] {
+ &:hover [data-slot="shell-rolling-actions"] {
+ opacity: 1;
+ }
+
+ &[data-clickable="true"] {
+ cursor: pointer;
+ }
+ }
+
+ [data-slot="shell-rolling-header"] {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ max-width: 100%;
+ height: 37px;
+ box-sizing: border-box;
+ }
+
+ [data-slot="shell-rolling-title"] {
+ flex-shrink: 0;
+ font-family: var(--font-family-sans);
+ font-size: 14px;
+ font-style: normal;
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large);
+ letter-spacing: var(--letter-spacing-normal);
+ color: var(--text-strong);
+ }
+
+ [data-slot="shell-rolling-subtitle"] {
+ flex: 0 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--font-family-sans);
+ font-size: 14px;
+ font-weight: var(--font-weight-normal);
+ line-height: var(--line-height-large);
+ color: var(--text-weak);
+ }
+
+ [data-slot="shell-rolling-actions"] {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ opacity: 0;
+ transition: opacity 0.15s ease;
+ }
+
+ .shell-rolling-copy {
+ border: none !important;
+ outline: none !important;
+ box-shadow: none !important;
+ background: transparent !important;
+
+ [data-slot="icon-svg"] {
+ color: var(--icon-weaker);
+ }
+
+ &:hover:not(:disabled) {
+ background: color-mix(in srgb, var(--text-base) 8%, transparent) !important;
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important;
+ border-radius: var(--radius-sm);
+
+ [data-slot="icon-svg"] {
+ color: var(--icon-base);
+ }
+ }
+ }
+
+ [data-slot="shell-rolling-arrow"] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--icon-weaker);
+ transform: rotate(-90deg);
+ transition: transform 0.15s ease;
+ }
+
+ [data-slot="shell-rolling-arrow"][data-open="true"] {
+ transform: rotate(0deg);
+ }
+}
+
+[data-component="shell-rolling-output"] {
+ width: 100%;
+ min-width: 0;
+}
+
+[data-slot="shell-rolling-preview"] {
+ width: 100%;
+ min-width: 0;
+}
+
+[data-component="shell-expanded-output"] {
+ width: 100%;
+ max-width: 100%;
+ overflow-y: auto;
+ overflow-x: hidden;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
+
+[data-component="shell-expanded-shell"] {
+ position: relative;
+ width: 100%;
+ min-width: 0;
+ border: 1px solid var(--border-weak-base);
+ border-radius: 6px;
+ background: transparent;
+ overflow: hidden;
+}
+
+[data-slot="shell-expanded-body"] {
+ position: relative;
+ width: 100%;
+ min-width: 0;
+}
+
+[data-slot="shell-expanded-top"] {
+ position: relative;
+ width: 100%;
+ min-width: 0;
+ padding: 9px 44px 9px 16px;
+ box-sizing: border-box;
+}
+
+[data-slot="shell-expanded-command"] {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ width: 100%;
+ min-width: 0;
+ font-family: var(--font-family-mono);
+ font-feature-settings: var(--font-family-mono--font-feature-settings);
+ font-size: 13px;
+ line-height: 1.45;
+}
+
+[data-slot="shell-expanded-prompt"] {
+ flex-shrink: 0;
+ color: var(--text-weaker);
+}
+
+[data-slot="shell-expanded-input"] {
+ min-width: 0;
+ color: var(--text-strong);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+[data-slot="shell-expanded-actions"] {
+ position: absolute;
+ top: 50%;
+ right: 8px;
+ z-index: 1;
+ transform: translateY(-50%);
+}
+
+.shell-expanded-copy {
+ border: none !important;
+ outline: none !important;
+ box-shadow: none !important;
+ background: transparent !important;
+
+ [data-slot="icon-svg"] {
+ color: var(--icon-weaker);
+ }
+
+ &:hover:not(:disabled) {
+ background: color-mix(in srgb, var(--text-base) 8%, transparent) !important;
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important;
+ border-radius: var(--radius-sm);
+
+ [data-slot="icon-svg"] {
+ color: var(--icon-base);
+ }
+ }
+}
+
+[data-slot="shell-expanded-divider"] {
+ width: 100%;
+ height: 1px;
+ background: var(--border-weak-base);
+}
+
+[data-slot="shell-expanded-pre"] {
+ margin: 0;
+ padding: 12px 16px;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+
+ code {
+ font-family: var(--font-family-mono);
+ font-feature-settings: var(--font-family-mono--font-feature-settings);
+ font-size: 13px;
+ line-height: 1.45;
+ color: var(--text-base);
+ }
+}
+
+[data-component="shell-rolling-command"],
+[data-component="shell-rolling-row"] {
+ display: inline-flex;
+ align-items: center;
+ width: 100%;
+ min-width: 0;
+ overflow: hidden;
+ white-space: pre;
+ padding-left: 12px;
+}
+
+[data-slot="shell-rolling-text"] {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-family: var(--font-family-mono);
+ font-feature-settings: var(--font-family-mono--font-feature-settings);
+ font-size: var(--font-size-small);
+ line-height: var(--line-height-large);
+}
+
+[data-component="shell-rolling-command"] [data-slot="shell-rolling-text"] {
+ color: var(--text-base);
+}
+
+[data-component="shell-rolling-command"] [data-slot="shell-rolling-prompt"] {
+ color: var(--text-weaker);
+}
+
+[data-component="shell-rolling-row"] [data-slot="shell-rolling-text"] {
+ color: var(--text-weak);
+}
+
[data-component="diagnostics"] {
display: flex;
flex-direction: column;
@@ -729,6 +1058,30 @@
width: 100%;
}
+[data-slot="assistant-part-grow"] {
+ width: 100%;
+ min-width: 0;
+ overflow: visible;
+}
+
+[data-component="tool-part-wrapper"][data-tool="bash"] {
+ [data-component="tool-trigger"] {
+ width: auto;
+ max-width: 100%;
+ }
+
+ [data-slot="basic-tool-tool-info-main"] {
+ align-items: center;
+ }
+
+ [data-slot="basic-tool-tool-title"],
+ [data-slot="basic-tool-tool-subtitle"] {
+ display: inline-flex;
+ align-items: center;
+ line-height: var(--line-height-large);
+ }
+}
+
[data-component="dock-prompt"][data-kind="permission"] {
position: relative;
display: flex;
@@ -1187,8 +1540,7 @@
position: sticky;
top: var(--sticky-accordion-top, 0px);
z-index: 20;
- height: 40px;
- padding-bottom: 8px;
+ height: 37px;
background-color: var(--background-stronger);
}
}
@@ -1199,11 +1551,12 @@
}
[data-slot="apply-patch-trigger-content"] {
- display: flex;
+ display: inline-flex;
align-items: center;
- justify-content: space-between;
- width: 100%;
- gap: 20px;
+ justify-content: flex-start;
+ max-width: 100%;
+ min-width: 0;
+ gap: 8px;
}
[data-slot="apply-patch-file-info"] {
@@ -1237,9 +1590,9 @@
[data-slot="apply-patch-trigger-actions"] {
flex-shrink: 0;
display: flex;
- gap: 16px;
+ gap: 8px;
align-items: center;
- justify-content: flex-end;
+ justify-content: flex-start;
}
[data-slot="apply-patch-change"] {
@@ -1279,10 +1632,11 @@
}
[data-component="tool-loaded-file"] {
+ min-width: 0;
display: flex;
align-items: center;
gap: 8px;
- padding: 4px 0 4px 28px;
+ padding: 4px 0 4px 12px;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-regular);
@@ -1293,4 +1647,11 @@
flex-shrink: 0;
color: var(--icon-weak);
}
+
+ span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index fbeb8bda28..be99f36fd2 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -1,18 +1,6 @@
-import {
- Component,
- createEffect,
- createMemo,
- createSignal,
- For,
- Match,
- onMount,
- Show,
- Switch,
- onCleanup,
- Index,
- type JSX,
-} from "solid-js"
+import { Component, createEffect, createMemo, createSignal, For, Match, on, Show, Switch, type JSX } from "solid-js"
import stripAnsi from "strip-ansi"
+import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import {
AgentPart,
@@ -31,13 +19,11 @@ import {
import { useData } from "../context"
import { useFileComponent } from "../context/file"
import { useDialog } from "../context/dialog"
-import { useI18n } from "../context/i18n"
-import { BasicTool } from "./basic-tool"
-import { GenericTool } from "./basic-tool"
+import { type UiI18n, useI18n } from "../context/i18n"
+import { GenericTool, ToolCall } from "./basic-tool"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { Card } from "./card"
-import { Collapsible } from "./collapsible"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
import { Checkbox } from "./checkbox"
@@ -49,43 +35,12 @@ import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
import { TextShimmer } from "./text-shimmer"
-import { AnimatedCountList } from "./tool-count-summary"
-import { ToolStatusTitle } from "./tool-status-title"
-import { animate } from "motion"
-import { useLocation } from "@solidjs/router"
-
-function ShellSubmessage(props: { text: string; animate?: boolean }) {
- let widthRef: HTMLSpanElement | undefined
- let valueRef: HTMLSpanElement | undefined
-
- onMount(() => {
- if (!props.animate) return
- requestAnimationFrame(() => {
- if (widthRef) {
- animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 })
- }
- if (valueRef) {
- animate(valueRef, { opacity: 1, filter: "blur(0px)" }, { duration: 0.32, ease: [0.16, 1, 0.3, 1] })
- }
- })
- })
-
- return (
-
-
-
-
- {props.text}
-
-
-
-
- )
-}
+import { list } from "./text-utils"
+import { GrowBox } from "./grow-box"
+import { COLLAPSIBLE_SPRING } from "./motion"
+import { busy, hold, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils"
+import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResults } from "./context-tool-results"
+import { ShellRollingResults } from "./shell-rolling-results"
interface Diagnostic {
range: {
@@ -126,64 +81,22 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element {
)
}
-export interface MessageProps {
- message: MessageType
- parts: PartType[]
- showAssistantCopyPartID?: string | null
- interrupted?: boolean
- queued?: boolean
- showReasoningSummaries?: boolean
-}
-
export interface MessagePartProps {
part: PartType
message: MessageType
hideDetails?: boolean
defaultOpen?: boolean
showAssistantCopyPartID?: string | null
- turnDurationMs?: number
+ showTurnDiffSummary?: boolean
+ turnDiffSummary?: () => JSX.Element
+ animate?: boolean
+ working?: boolean
}
export type PartComponent = Component
export const PART_MAPPING: Record = {}
-const TEXT_RENDER_THROTTLE_MS = 100
-
-function createThrottledValue(getValue: () => string) {
- const [value, setValue] = createSignal(getValue())
- let timeout: ReturnType | undefined
- let last = 0
-
- createEffect(() => {
- const next = getValue()
- const now = Date.now()
-
- const remaining = TEXT_RENDER_THROTTLE_MS - (now - last)
- if (remaining <= 0) {
- if (timeout) {
- clearTimeout(timeout)
- timeout = undefined
- }
- last = now
- setValue(next)
- return
- }
- if (timeout) clearTimeout(timeout)
- timeout = setTimeout(() => {
- last = Date.now()
- setValue(next)
- timeout = undefined
- }, remaining)
- })
-
- onCleanup(() => {
- if (timeout) clearTimeout(timeout)
- })
-
- return value
-}
-
function relativizeProjectPath(path: string, directory?: string) {
if (!path) return ""
if (!directory) return path
@@ -210,6 +123,11 @@ export type ToolInfo = {
subtitle?: string
}
+function agentTitle(i18n: UiI18n, type?: string) {
+ if (!type) return i18n.t("ui.tool.agent.default")
+ return i18n.t("ui.tool.agent", { type })
+}
+
export function getToolInfo(tool: string, input: any = {}): ToolInfo {
const i18n = useI18n()
switch (tool) {
@@ -255,12 +173,17 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
title: i18n.t("ui.tool.codesearch"),
subtitle: input.query,
}
- case "task":
+ case "task": {
+ const type =
+ typeof input.subagent_type === "string" && input.subagent_type
+ ? input.subagent_type[0]!.toUpperCase() + input.subagent_type.slice(1)
+ : undefined
return {
icon: "task",
- title: i18n.t("ui.tool.agent", { type: input.subagent_type || "task" }),
+ title: agentTitle(i18n, type),
subtitle: input.description,
}
+ }
case "bash":
return {
icon: "console",
@@ -305,7 +228,8 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
case "skill":
return {
icon: "brain",
- title: input.name || "skill",
+ title: i18n.t("ui.tool.skill"),
+ subtitle: typeof input.name === "string" ? input.name : undefined,
}
default:
return {
@@ -330,105 +254,36 @@ function urls(text: string | undefined) {
const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
const HIDDEN_TOOLS = new Set(["todowrite", "todoread"])
-function list(value: T[] | undefined | null, fallback: T[]) {
- if (Array.isArray(value)) return value
- return fallback
-}
+import { pageVisible } from "../hooks/use-page-visible"
-function same(a: readonly T[] | undefined, b: readonly T[] | undefined) {
- if (a === b) return true
- if (!a || !b) return false
- if (a.length !== b.length) return false
- return a.every((x, i) => x === b[i])
-}
-
-type PartRef = {
- messageID: string
- partID: string
-}
-
-type PartGroup =
- | {
- key: string
- type: "part"
- ref: PartRef
- }
- | {
- key: string
- type: "context"
- refs: PartRef[]
- }
-
-function sameRef(a: PartRef, b: PartRef) {
- return a.messageID === b.messageID && a.partID === b.partID
-}
-
-function sameGroup(a: PartGroup, b: PartGroup) {
- if (a === b) return true
- if (a.key !== b.key) return false
- if (a.type !== b.type) return false
- if (a.type === "part") {
- if (b.type !== "part") return false
- return sameRef(a.ref, b.ref)
+function createGroupOpenState() {
+ const [state, setState] = createStore>({})
+ const read = (key?: string, collapse?: boolean) => {
+ if (!key) return true
+ const value = state[key]
+ if (value !== undefined) return value
+ return !collapse
}
- if (b.type !== "context") return false
- if (a.refs.length !== b.refs.length) return false
- return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!))
-}
-
-function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) {
- if (a === b) return true
- if (!a || !b) return false
- if (a.length !== b.length) return false
- return a.every((item, i) => sameGroup(item, b[i]!))
-}
-
-function groupParts(parts: { messageID: string; part: PartType }[]) {
- const result: PartGroup[] = []
- let start = -1
-
- const flush = (end: number) => {
- if (start < 0) return
- const first = parts[start]
- const last = parts[end]
- if (!first || !last) {
- start = -1
- return
- }
- result.push({
- key: `context:${first.part.id}`,
- type: "context",
- refs: parts.slice(start, end + 1).map((item) => ({
- messageID: item.messageID,
- partID: item.part.id,
- })),
- })
- start = -1
+ const controlled = (key?: string) => {
+ if (!key) return false
+ return state[key] !== undefined
}
-
- parts.forEach((item, index) => {
- if (isContextGroupTool(item.part)) {
- if (start < 0) start = index
- return
- }
-
- flush(index - 1)
- result.push({
- key: `part:${item.messageID}:${item.part.id}`,
- type: "part",
- ref: {
- messageID: item.messageID,
- partID: item.part.id,
- },
- })
- })
-
- flush(parts.length - 1)
- return result
+ const write = (key: string, value: boolean) => {
+ setState(key, value)
+ }
+ return { read, controlled, write }
}
-function partByID(parts: readonly PartType[], partID: string) {
- return parts.find((part) => part.id === partID)
+function shouldCollapseGroup(
+ statuses: (string | undefined)[],
+ opts: { afterTool?: boolean; groupTail?: boolean; working?: boolean },
+) {
+ if (opts.afterTool) return true
+ if (opts.groupTail === false) return true
+ if (!pageVisible()) return false
+ if (opts.working) return false
+ if (!statuses.length) return false
+ return !statuses.some((s) => busy(s))
}
function renderable(part: PartType, showReasoningSummaries = true) {
@@ -444,7 +299,8 @@ function renderable(part: PartType, showReasoningSummaries = true) {
function toolDefaultOpen(tool: string, shell = false, edit = false) {
if (tool === "bash") return shell
- if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit
+ if (tool === "edit" || tool === "write") return edit
+ if (tool === "apply_patch") return false
}
function partDefaultOpen(part: PartType, shell = false, edit = false) {
@@ -452,99 +308,328 @@ function partDefaultOpen(part: PartType, shell = false, edit = false) {
return toolDefaultOpen(part.tool, shell, edit)
}
+function PartGrow(props: {
+ children: JSX.Element
+ animate?: boolean
+ animateToggle?: boolean
+ gap?: number
+ fade?: boolean
+ edge?: boolean
+ edgeHeight?: number
+ edgeOpacity?: number
+ edgeIdle?: number
+ edgeFade?: number
+ edgeRise?: number
+ grow?: boolean
+ watch?: boolean
+ open?: boolean
+ spring?: import("./motion").SpringConfig
+ toggleSpring?: import("./motion").SpringConfig
+}) {
+ return (
+
+ {props.children}
+
+ )
+}
+
export function AssistantParts(props: {
messages: AssistantMessage[]
showAssistantCopyPartID?: string | null
- turnDurationMs?: number
+ showTurnDiffSummary?: boolean
+ turnDiffSummary?: () => JSX.Element
working?: boolean
showReasoningSummaries?: boolean
shellToolDefaultOpen?: boolean
editToolDefaultOpen?: boolean
+ animate?: boolean
}) {
const data = useData()
const emptyParts: PartType[] = []
- const emptyTools: ToolPart[] = []
+ const groupState = createGroupOpenState()
+ const grouped = createMemo(() => {
+ const keys: string[] = []
+ const items: Record<
+ string,
+ | {
+ type: "part"
+ part: PartType
+ message: AssistantMessage
+ context?: boolean
+ groupKey?: string
+ afterTool?: boolean
+ groupTail?: boolean
+ groupParts?: { part: ToolPart; message: AssistantMessage }[]
+ }
+ | {
+ type: "context"
+ groupKey: string
+ parts: { part: ToolPart; message: AssistantMessage }[]
+ tail: boolean
+ afterTool: boolean
+ }
+ > = {}
+ const push = (key: string, item: (typeof items)[string]) => {
+ keys.push(key)
+ items[key] = item
+ }
+ const id = (part: PartType) => {
+ if (part.type === "tool") return part.callID || part.id
+ return part.id
+ }
+ const parts = props.messages.flatMap((message) =>
+ list(data.store.part?.[message.id], emptyParts)
+ .filter((part) => renderable(part, props.showReasoningSummaries ?? true))
+ .map((part) => ({ message, part })),
+ )
- const grouped = createMemo(
- () =>
- groupParts(
- props.messages.flatMap((message) =>
- list(data.store.part?.[message.id], emptyParts)
- .filter((part) => renderable(part, props.showReasoningSummaries ?? true))
- .map((part) => ({
- messageID: message.id,
- part,
- })),
- ),
- ),
- [] as PartGroup[],
- { equals: sameGroups },
- )
+ let start = -1
- const last = createMemo(() => grouped().at(-1)?.key)
+ const flush = (end: number, tail: boolean, afterTool: boolean) => {
+ if (start < 0) return
+ const group = parts
+ .slice(start, end + 1)
+ .filter((entry): entry is { part: ToolPart; message: AssistantMessage } => isContextGroupTool(entry.part))
+ if (!group.length) {
+ start = -1
+ return
+ }
+ const groupKey = `context:${group[0].message.id}:${id(group[0].part)}`
+ push(groupKey, {
+ type: "context",
+ groupKey,
+ parts: group,
+ tail,
+ afterTool,
+ })
+ group.forEach((entry) => {
+ push(`part:${entry.message.id}:${id(entry.part)}`, {
+ type: "part",
+ part: entry.part,
+ message: entry.message,
+ context: true,
+ groupKey,
+ afterTool,
+ groupTail: tail,
+ groupParts: group,
+ })
+ })
+ start = -1
+ }
+ parts.forEach((item, index) => {
+ if (isContextGroupTool(item.part)) {
+ if (start < 0) start = index
+ return
+ }
+
+ flush(index - 1, false, (item as { part: PartType }).part.type === "tool")
+ push(`part:${item.message.id}:${id(item.part)}`, { type: "part", part: item.part, message: item.message })
+ })
+
+ flush(parts.length - 1, true, false)
+ return { keys, items }
+ })
+
+ const last = createMemo(() => grouped().keys.at(-1))
return (
-
- {(entryAccessor) => {
- const entryType = createMemo(() => entryAccessor().type)
+
+
+ {(key) => {
+ const item = createMemo(() => grouped().items[key])
+ const ctx = createMemo(() => {
+ const value = item()
+ if (!value) return
+ if (value.type !== "context") return
+ return value
+ })
+ const part = createMemo(() => {
+ const value = item()
+ if (!value) return
+ if (value.type !== "part") return
+ return value
+ })
+ const tail = createMemo(() => last() === key)
+ const tool = createMemo(() => {
+ const value = part()
+ if (!value) return false
+ return value.part.type === "tool"
+ })
+ const context = createMemo(() => !!part()?.context)
+ const contextSpring = createMemo(() => {
+ const entry = part()
+ if (!entry?.context) return undefined
+ if (!groupState.controlled(entry.groupKey)) return undefined
+ return COLLAPSIBLE_SPRING
+ })
+ const contextOpen = createMemo(() => {
+ const collapse = (
+ afterTool?: boolean,
+ groupTail?: boolean,
+ group?: { part: ToolPart; message: AssistantMessage }[],
+ ) =>
+ shouldCollapseGroup(group?.map((item) => item.part.state.status) ?? [], {
+ afterTool,
+ groupTail,
+ working: props.working,
+ })
+ const value = ctx()
+ if (value) return groupState.read(value.groupKey, collapse(value.afterTool, value.tail, value.parts))
+ const entry = part()
+ return groupState.read(entry?.groupKey, collapse(entry?.afterTool, entry?.groupTail, entry?.groupParts))
+ })
+ const visible = createMemo(() => {
+ if (!context()) return true
+ if (ctx()) return true
+ return false
+ })
- return (
-
-
- {(() => {
- const parts = createMemo(
- () => {
- const entry = entryAccessor() as { type: "context"; refs: PartRef[] }
- return entry.refs
- .map((ref) => partByID(list(data.store.part?.[ref.messageID], emptyParts), ref.partID))
- .filter((part): part is ToolPart => !!part && isContextGroupTool(part))
- },
- emptyTools,
- { equals: same },
- )
- const busy = createMemo(() => props.working && last() === entryAccessor().key)
-
- return (
- 0}>
-
-
- )
- })()}
-
-
- {(() => {
- const message = createMemo(() => {
- const entry = entryAccessor() as { type: "part"; ref: PartRef }
- return props.messages.find((item) => item.id === entry.ref.messageID)
- })
- const part = createMemo(() => {
- const entry = entryAccessor() as { type: "part"; ref: PartRef }
- return partByID(list(data.store.part?.[entry.ref.messageID], emptyParts), entry.ref.partID)
- })
-
- return (
-
- {(msg) => (
-
- {(p) => (
+ const turnSummary = createMemo(() => {
+ const value = part()
+ if (!value) return false
+ if (value.part.type !== "text") return false
+ if (!props.showTurnDiffSummary) return false
+ return props.showAssistantCopyPartID === value.part.id
+ })
+ const fade = createMemo(() => {
+ if (ctx()) return true
+ return tool()
+ })
+ const edge = createMemo(() => {
+ const entry = part()
+ if (!entry) return false
+ if (entry.part.type !== "text") return false
+ if (!props.working) return false
+ return tail()
+ })
+ const watch = createMemo(() => !context() && !tool() && tail() && !turnSummary())
+ const ctxPartsCache = new Map()
+ let ctxPartsPrev: ToolPart[] = []
+ const ctxParts = createMemo(() => {
+ const parts = ctx()?.parts ?? []
+ if (parts.length === 0 && ctxPartsPrev.length > 0) return ctxPartsPrev
+ const result: ToolPart[] = []
+ for (const item of parts) {
+ const k = item.part.callID || item.part.id
+ const cached = ctxPartsCache.get(k)
+ if (cached) {
+ result.push(cached)
+ } else {
+ ctxPartsCache.set(k, item.part)
+ result.push(item.part)
+ }
+ }
+ ctxPartsPrev = result
+ return result
+ })
+ const ctxPendingRaw = useContextToolPending(ctxParts, () => !!(props.working && ctx()?.tail))
+ const ctxPending = ctxPendingRaw
+ const ctxHoldOpen = hold(ctxPendingRaw)
+ const shell = createMemo(() => {
+ const value = part()
+ if (!value) return
+ if (value.part.type !== "tool") return
+ if (value.part.tool !== "bash") return
+ return value.part
+ })
+ const kind = createMemo(() => {
+ if (ctx()) return "context"
+ if (shell()) return "shell"
+ const value = part()
+ if (!value) return "part"
+ return value.part.type
+ })
+ const shown = createMemo(() => {
+ if (ctx()) return true
+ if (shell()) return true
+ const entry = part()
+ if (!entry) return false
+ return !entry.context
+ })
+ const partGrowProps = () => ({
+ animate: props.animate,
+ gap: 0,
+ fade: fade(),
+ edge: edge(),
+ edgeHeight: 20,
+ edgeOpacity: 0.95,
+ edgeIdle: 100,
+ edgeFade: 0.6,
+ edgeRise: 0.1,
+ grow: true,
+ watch: watch(),
+ animateToggle: true,
+ open: visible(),
+ toggleSpring: contextSpring(),
+ })
+ return (
+
+
+
+ {(entry) => (
+ <>
+
+ groupState.write(entry().groupKey, value)}
+ />
+
+
+
+ >
+ )}
+
+
{(value) => }
+
+ {(entry) => (
+
+
+
- )}
-
- )}
-
- )
- })()}
-
-
- )
- }}
-
+
+
+
+ )}
+
+
+
+ )
+ }}
+
+
)
}
@@ -552,76 +637,6 @@ function isContextGroupTool(part: PartType): part is ToolPart {
return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool)
}
-function contextToolDetail(part: ToolPart): string | undefined {
- const info = getToolInfo(part.tool, part.state.input ?? {})
- if (info.subtitle) return info.subtitle
- if (part.state.status === "error") return part.state.error
- if ((part.state.status === "running" || part.state.status === "completed") && part.state.title)
- return part.state.title
- const description = part.state.input?.description
- if (typeof description === "string") return description
- return undefined
-}
-
-function contextToolTrigger(part: ToolPart, i18n: ReturnType) {
- const input = (part.state.input ?? {}) as Record
- const path = typeof input.path === "string" ? input.path : "/"
- const filePath = typeof input.filePath === "string" ? input.filePath : undefined
- const pattern = typeof input.pattern === "string" ? input.pattern : undefined
- const include = typeof input.include === "string" ? input.include : undefined
- const offset = typeof input.offset === "number" ? input.offset : undefined
- const limit = typeof input.limit === "number" ? input.limit : undefined
-
- switch (part.tool) {
- case "read": {
- const args: string[] = []
- if (offset !== undefined) args.push("offset=" + offset)
- if (limit !== undefined) args.push("limit=" + limit)
- return {
- title: i18n.t("ui.tool.read"),
- subtitle: filePath ? getFilename(filePath) : "",
- args,
- }
- }
- case "list":
- return {
- title: i18n.t("ui.tool.list"),
- subtitle: getDirectory(path),
- }
- case "glob":
- return {
- title: i18n.t("ui.tool.glob"),
- subtitle: getDirectory(path),
- args: pattern ? ["pattern=" + pattern] : [],
- }
- case "grep": {
- const args: string[] = []
- if (pattern) args.push("pattern=" + pattern)
- if (include) args.push("include=" + include)
- return {
- title: i18n.t("ui.tool.grep"),
- subtitle: getDirectory(path),
- args,
- }
- }
- default: {
- const info = getToolInfo(part.tool, input)
- return {
- title: info.title,
- subtitle: info.subtitle || contextToolDetail(part),
- args: [],
- }
- }
- }
-}
-
-function contextToolSummary(parts: ToolPart[]) {
- const read = parts.filter((part) => part.tool === "read").length
- const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length
- const list = parts.filter((part) => part.tool === "list").length
- return { read, search, list }
-}
-
function ExaOutput(props: { output?: string }) {
const links = createMemo(() => urls(props.output))
@@ -652,210 +667,11 @@ export function registerPartComponent(type: string, component: PartComponent) {
PART_MAPPING[type] = component
}
-export function Message(props: MessageProps) {
- return (
-
-
- {(userMessage) => (
-
- )}
-
-
- {(assistantMessage) => (
-
- )}
-
-
- )
-}
-
-export function AssistantMessageDisplay(props: {
- message: AssistantMessage
- parts: PartType[]
- showAssistantCopyPartID?: string | null
- showReasoningSummaries?: boolean
-}) {
- const emptyTools: ToolPart[] = []
- const grouped = createMemo(
- () =>
- groupParts(
- props.parts
- .filter((part) => renderable(part, props.showReasoningSummaries ?? true))
- .map((part) => ({
- messageID: props.message.id,
- part,
- })),
- ),
- [] as PartGroup[],
- { equals: sameGroups },
- )
-
- return (
-
- {(entryAccessor) => {
- const entryType = createMemo(() => entryAccessor().type)
-
- return (
-
-
- {(() => {
- const parts = createMemo(
- () => {
- const entry = entryAccessor() as { type: "context"; refs: PartRef[] }
- return entry.refs
- .map((ref) => partByID(props.parts, ref.partID))
- .filter((part): part is ToolPart => !!part && isContextGroupTool(part))
- },
- emptyTools,
- { equals: same },
- )
-
- return (
- 0}>
-
-
- )
- })()}
-
-
- {(() => {
- const part = createMemo(() => {
- const entry = entryAccessor() as { type: "part"; ref: PartRef }
- return partByID(props.parts, entry.ref.partID)
- })
-
- return (
-
- {(p) => (
-
- )}
-
- )
- })()}
-
-
- )
- }}
-
- )
-}
-
-function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
- const i18n = useI18n()
- const [open, setOpen] = createSignal(false)
- const pending = createMemo(
- () =>
- !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
- )
- const summary = createMemo(() => contextToolSummary(props.parts))
-
- return (
-
-
-
-
-
-
-
- {(partAccessor) => {
- const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n))
- const running = createMemo(
- () => partAccessor().state.status === "pending" || partAccessor().state.status === "running",
- )
- return (
-
-
-
-
-
-
-
-
-
-
- {trigger().subtitle}
-
-
-
- {(arg) => {arg}}
-
-
-
-
-
-
-
-
- )
- }}
-
-
-
-
- )
-}
-
export function UserMessageDisplay(props: {
message: UserMessage
parts: PartType[]
interrupted?: boolean
+ animate?: boolean
queued?: boolean
}) {
const data = useData()
@@ -905,14 +721,9 @@ export function UserMessageDisplay(props: {
return `${hour12}:${minute} ${hours < 12 ? "AM" : "PM"}`
})
- const metaHead = createMemo(() => {
+ const userMeta = createMemo(() => {
const agent = props.message.agent
- const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model()]
- return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0")
- })
-
- const metaTail = createMemo(() => {
- const items = [stamp(), props.interrupted ? i18n.t("ui.message.interrupted") : ""]
+ const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model(), stamp()]
return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0")
})
@@ -929,93 +740,83 @@ export function UserMessageDisplay(props: {
}
return (
-
-
0}>
-
-
- {(file) => (
- {
- if (file.mime.startsWith("image/") && file.url) {
- openImagePreview(file.url, file.filename)
- }
- }}
- >
-
-
-
- }
- >
-
-
-
- )}
-
-
-
-
- <>
-
-
-
+
+
+
+
0}>
+
+
+ {(file) => (
+ {
+ if (file.mime.startsWith("image/") && file.url) {
+ openImagePreview(file.url, file.filename)
+ }
+ }}
+ >
+
+
+
+ }
+ >
+
+
+
+ )}
+
-
-
-
-
-
-
+
+
- {metaHead()}
+ {userMeta()}
-
-
- {"\u00A0\u00B7\u00A0"}
-
-
-
-
- {metaTail()}
-
-
-
-
-
- e.preventDefault()}
- onClick={(event) => {
- event.stopPropagation()
- handleCopy()
- }}
- aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
- />
-
-
- >
-
-
+
+ e.preventDefault()}
+ onClick={(event) => {
+ event.stopPropagation()
+ handleCopy()
+ }}
+ aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
+ />
+
+
+ >
+
+
+
+
)
}
@@ -1069,7 +870,10 @@ export function Part(props: MessagePartProps) {
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
showAssistantCopyPartID={props.showAssistantCopyPartID}
- turnDurationMs={props.turnDurationMs}
+ showTurnDiffSummary={props.showTurnDiffSummary}
+ turnDiffSummary={props.turnDiffSummary}
+ animate={props.animate}
+ working={props.working}
/>
)
@@ -1079,12 +883,16 @@ export interface ToolProps {
input: Record
metadata: Record
tool: string
+ partID?: string
+ callID?: string
output?: string
status?: string
hideDetails?: boolean
defaultOpen?: boolean
forceOpen?: boolean
locked?: boolean
+ animate?: boolean
+ reveal?: boolean
}
export type ToolComponent = Component
@@ -1118,7 +926,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
@@ -1149,30 +957,26 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
PART_MAPPING["tool"] = function ToolPartDisplay(props) {
const i18n = useI18n()
- const part = () => props.part as ToolPart
- if (part().tool === "todowrite" || part().tool === "todoread") return null
-
- const hideQuestion = createMemo(
- () => part().tool === "question" && (part().state.status === "pending" || part().state.status === "running"),
- )
+ const part = props.part as ToolPart
+ const hideQuestion = createMemo(() => part.tool === "question" && busy(part.state.status))
const emptyInput: Record = {}
const emptyMetadata: Record = {}
- const input = () => part().state?.input ?? emptyInput
+ const input = () => part.state?.input ?? emptyInput
// @ts-expect-error
- const partMetadata = () => part().state?.metadata ?? emptyMetadata
+ const partMetadata = () => part.state?.metadata ?? emptyMetadata
- const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool)
+ const render = createMemo(() => ToolRegistry.render(part.tool) ?? GenericTool)
return (
-
+
-
+
{(error) => {
const cleaned = error().replace("Error: ", "")
- if (part().tool === "question" && cleaned.includes("dismissed this question")) {
+ if (part.tool === "question" && cleaned.includes("dismissed this question")) {
return (
@@ -1206,13 +1010,17 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
@@ -1237,74 +1045,16 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() {
}
PART_MAPPING["text"] = function TextPartDisplay(props) {
- const data = useData()
- const i18n = useI18n()
const part = () => props.part as TextPart
- const interrupted = createMemo(
- () =>
- props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError",
- )
-
- const model = createMemo(() => {
- if (props.message.role !== "assistant") return ""
- const message = props.message as AssistantMessage
- const match = data.store.provider?.all?.find((p) => p.id === message.providerID)
- return match?.models?.[message.modelID]?.name ?? message.modelID
- })
-
- const duration = createMemo(() => {
- if (props.message.role !== "assistant") return ""
- const message = props.message as AssistantMessage
- const completed = message.time.completed
- const ms =
- typeof props.turnDurationMs === "number"
- ? props.turnDurationMs
- : typeof completed === "number"
- ? completed - message.time.created
- : -1
- if (!(ms >= 0)) return ""
- const total = Math.round(ms / 1000)
- if (total < 60) return `${total}s`
- const minutes = Math.floor(total / 60)
- const seconds = total % 60
- return `${minutes}m ${seconds}s`
- })
-
- const meta = createMemo(() => {
- if (props.message.role !== "assistant") return ""
- const agent = (props.message as AssistantMessage).agent
- const items = [
- agent ? agent[0]?.toUpperCase() + agent.slice(1) : "",
- model(),
- duration(),
- interrupted() ? i18n.t("ui.message.interrupted") : "",
- ]
- return items.filter((x) => !!x).join(" \u00B7 ")
- })
const displayText = () => (part().text ?? "").trim()
const throttledText = createThrottledValue(displayText)
- const isLastTextPart = createMemo(() => {
- const last = (data.store.part?.[props.message.id] ?? [])
- .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
- .at(-1)
- return last?.id === part().id
+ const summary = createMemo(() => {
+ if (props.message.role !== "assistant") return
+ if (!props.showTurnDiffSummary) return
+ if (props.showAssistantCopyPartID !== part().id) return
+ return props.turnDiffSummary
})
- const showCopy = createMemo(() => {
- if (props.message.role !== "assistant") return isLastTextPart()
- if (props.showAssistantCopyPartID === null) return false
- if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id
- return isLastTextPart()
- })
- const [copied, setCopied] = createSignal(false)
-
- const handleCopy = async () => {
- const content = displayText()
- if (!content) return
- await navigator.clipboard.writeText(content)
- setCopied(true)
- setTimeout(() => setCopied(false), 2000)
- }
return (
@@ -1312,28 +1062,12 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
-
-
-
- e.preventDefault()}
- onClick={handleCopy}
- aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
- />
-
-
-
- {meta()}
-
-
-
+
+ {(render) => (
+
+ {render()()}
+
+ )}
@@ -1363,30 +1097,33 @@ ToolRegistry.register({
if (props.input.offset) args.push("offset=" + props.input.offset)
if (props.input.limit) args.push("limit=" + props.input.limit)
const loaded = createMemo(() => {
- if (props.status !== "completed") return []
const value = props.metadata.loaded
if (!value || !Array.isArray(value)) return []
return value.filter((p): p is string => typeof p === "string")
})
+ const pending = createMemo(() => busy(props.status))
return (
<>
-
+ }
/>
{(filepath) => (
-
-
-
- {i18n.t("ui.tool.loaded")} {relativizeProjectPath(filepath, data.directory)}
-
-
+
)}
>
@@ -1398,11 +1135,20 @@ ToolRegistry.register({
name: "list",
render(props) {
const i18n = useI18n()
+ const pending = createMemo(() => busy(props.status))
return (
-
+ }
>
{(output) => (
@@ -1411,7 +1157,7 @@ ToolRegistry.register({
)}
-
+
)
},
})
@@ -1420,15 +1166,21 @@ ToolRegistry.register({
name: "glob",
render(props) {
const i18n = useI18n()
+ const pending = createMemo(() => busy(props.status))
return (
-
+ }
>
{(output) => (
@@ -1437,7 +1189,7 @@ ToolRegistry.register({
)}
-
+
)
},
})
@@ -1449,15 +1201,21 @@ ToolRegistry.register({
const args: string[] = []
if (props.input.pattern) args.push("pattern=" + props.input.pattern)
if (props.input.include) args.push("include=" + props.input.include)
+ const pending = createMemo(() => busy(props.status))
return (
-
+ }
>
{(output) => (
@@ -1466,25 +1224,191 @@ ToolRegistry.register({
)}
-
+
)
},
})
+function useToolReveal(pending: () => boolean, animate?: () => boolean) {
+ const enabled = () => animate?.() ?? true
+ const [live, setLive] = createSignal(pending() || enabled())
+ createEffect(() => {
+ if (pending()) setLive(true)
+ })
+ return () => enabled() && live()
+}
+
+function WebfetchMeta(props: { url: string; animate?: boolean }) {
+ let ref: HTMLSpanElement | undefined
+ useToolFade(() => ref, { wipe: true, animate: props.animate })
+
+ return (
+
+ event.stopPropagation()}
+ >
+ {props.url}
+
+
+
+
+
+ )
+}
+
+function TaskLink(props: { href: string; text: string; onClick: (e: MouseEvent) => void; animate?: boolean }) {
+ let ref: HTMLAnchorElement | undefined
+ useToolFade(() => ref, { wipe: true, animate: props.animate })
+
+ return (
+
+ {props.text}
+
+ )
+}
+
+function ToolText(props: { text: string; delay?: number; animate?: boolean }) {
+ let ref: HTMLSpanElement | undefined
+ useToolFade(() => ref, { delay: props.delay, wipe: true, animate: props.animate })
+
+ return (
+
+ {props.text}
+
+ )
+}
+
+function ToolLoadedFile(props: { text: string; animate?: boolean }) {
+ let ref: HTMLDivElement | undefined
+ useToolFade(() => ref, { delay: 0.02, wipe: true, animate: props.animate })
+
+ return (
+
+
+
+ {props.text}
+
+
+ )
+}
+
+function ToolTriggerRow(props: {
+ title: string
+ pending: boolean
+ subtitle?: string
+ args?: string[]
+ action?: JSX.Element
+ animate?: boolean
+ revealOnMount?: boolean
+}) {
+ const reveal = useToolReveal(
+ () => props.pending,
+ () => props.animate !== false,
+ )
+ const detail = createMemo(() => [props.subtitle, ...(props.args ?? [])].filter((x): x is string => !!x).join(" "))
+ const detailAnimate = createMemo(() => {
+ if (props.animate === false) return false
+ if (props.revealOnMount) return true
+ if (!props.pending && !reveal()) return true
+ return reveal()
+ })
+
+ return (
+
+
+
+
+
+ {(text) => }
+
+
{props.action}
+
+ )
+}
+
+type DiffValue = { additions: number; deletions: number } | { additions: number; deletions: number }[]
+
+function ToolMetaLine(props: {
+ filename: string
+ path?: string
+ changes?: DiffValue
+ delay?: number
+ animate?: boolean
+ soft?: boolean
+}) {
+ let ref: HTMLSpanElement | undefined
+ useToolFade(() => ref, { delay: props.delay ?? 0.02, wipe: true, animate: props.animate })
+
+ return (
+
+ {props.filename}
+
+ {props.path}
+
+ {(changes) => }
+
+ )
+}
+
+function ToolChanges(props: { changes: DiffValue; animate?: boolean }) {
+ let ref: HTMLDivElement | undefined
+ useToolFade(() => ref, { delay: 0.04, animate: props.animate })
+
+ return (
+
+
+
+ )
+}
+
+function ShellText(props: { text: string; animate?: boolean }) {
+ let ref: HTMLSpanElement | undefined
+ useToolFade(() => ref, { wipe: true, animate: props.animate })
+
+ return (
+
+
+
+ {props.text}
+
+
+
+ )
+}
+
ToolRegistry.register({
name: "webfetch",
render(props) {
const i18n = useI18n()
- const pending = createMemo(() => props.status === "pending" || props.status === "running")
+ const pending = createMemo(() => busy(props.status))
+ const reveal = useToolReveal(pending, () => props.reveal !== false)
const url = createMemo(() => {
const value = props.input.url
if (typeof value !== "string") return ""
return value
})
return (
-
@@ -1492,24 +1416,8 @@ ToolRegistry.register({
-
- event.stopPropagation()}
- >
- {url()}
-
-
+ {(value) => }
-
-
-
-
-
}
/>
@@ -1528,7 +1436,8 @@ ToolRegistry.register({
})
return (
-