Merge remote-tracking branch 'origin/dev' into codemode-opencode-adapter

# Conflicts:
#	packages/opencode/src/mcp/index.ts
This commit is contained in:
Aiden Cline 2026-07-03 03:07:36 -05:00
commit da6626b4c1
21 changed files with 519 additions and 51 deletions

View file

@ -218,6 +218,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement
let restoreEndOnFocus = true
let savedCursor: number | null = null
const mirror = { input: false }
const inset = 56
@ -590,7 +591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const restoreFocus = () => {
requestAnimationFrame(() => {
const cursor = prompt.cursor() ?? promptLength(prompt.current())
const cursor = savedCursor ?? prompt.cursor() ?? promptLength(prompt.current())
editorRef.focus()
setCursorPosition(editorRef, cursor)
queueScroll()
@ -627,6 +628,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
const handleBlur = () => {
savedCursor = currentCursor()
closePopover()
setComposing(false)
}

View file

@ -399,7 +399,7 @@
overflow: hidden;
font-size: 13px;
font-weight: 440;
line-height: 1;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}

View file

@ -3,7 +3,7 @@ import { createSignal, Show, type JSXElement } from "solid-js"
import "./titlebar-tab-popover.css"
// Initial hover delay before the preview appears, per design.
const OPEN_DELAY = 200
const OPEN_DELAY = 400
// Mouse-out delay: begin closing immediately (a brief exit animation plays).
const CLOSE_DELAY = 0
// After a preview closes, hovering a neighbouring tab within this window skips

View file

@ -1,7 +1,8 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createEffect, createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createServerProjects, ServerConnection, useServer } from "./server"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
import { pathKey } from "@/utils/path-key"
import { useServerHealth } from "@/utils/server-health"
import { createServerSdkContext } from "./server-sdk"
import { createServerSyncContext } from "./server-sync"
@ -127,6 +128,14 @@ function createServerCtx(
}
const projectsList = createMemo(() => projects.list().map(enrich))
const recentlyClosedList = createMemo(() => {
const known = new Set(sync.data.project.map((project) => pathKey(project.worktree)))
return projects
.recentlyClosed()
.filter((worktree) => known.has(pathKey(worktree)))
.slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
.map((worktree) => enrich({ worktree, expanded: false }))
})
const isLocal =
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
@ -139,6 +148,7 @@ function createServerCtx(
projects: {
...projects,
list: projectsList,
recentlyClosed: recentlyClosedList,
},
}
}

View file

@ -5,10 +5,11 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useServerSync } from "./server-sync"
import { useServerSDK } from "./server-sdk"
import { ServerConnection, useServer } from "./server"
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { pathKey } from "@/utils/path-key"
import { decode64 } from "@/utils/base64"
import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
@ -493,7 +494,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const root = rootFor(project.worktree)
if (root === project.worktree) continue
server.projects.close(project.worktree)
server.projects.remove(project.worktree)
if (!seen.has(root)) {
server.projects.open(root)
@ -613,6 +614,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
projects: {
list,
recentlyClosed: createMemo(() => {
const known = new Set(serverSync().data.project.map((project) => pathKey(project.worktree)))
return server.projects
.recentlyClosed()
.filter((worktree) => known.has(pathKey(worktree)))
.slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
.map((worktree) => enrich({ worktree, expanded: false }))
}),
open(directory: string) {
const root = rootFor(directory)
if (server.projects.list().find((x) => x.worktree === root)) return

View file

@ -99,7 +99,7 @@ describe("createServerProjects", () => {
test("keeps active and explicit server buckets in one reactive store", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {} })
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const active = createServerProjects({ scope, store, setStore })
const remote = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
@ -115,6 +115,88 @@ describe("createServerProjects", () => {
dispose()
})
})
test("tracks recently closed projects and drops them when reopened", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const projects = createServerProjects({ scope, store, setStore })
projects.open("/a")
projects.open("/b")
projects.close("/a")
expect(projects.recentlyClosed()).toEqual(["/a"])
projects.close("/b")
expect(projects.recentlyClosed()).toEqual(["/b", "/a"])
projects.open("/a")
expect(projects.recentlyClosed()).toEqual(["/b"])
expect(projects.list()).toEqual([{ worktree: "/a", expanded: true }])
dispose()
})
})
test("remove drops a project without recording it as recently closed", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const projects = createServerProjects({ scope, store, setStore })
projects.open("/repo/subdir")
projects.remove("/repo/subdir")
expect(projects.list()).toEqual([])
expect(projects.recentlyClosed()).toEqual([])
dispose()
})
})
test("retains recently closed history beyond the visible display limit", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const projects = createServerProjects({ scope, store, setStore })
// Closing 6 projects keeps all 6 in the store even though only 5 are displayed;
// this prevents display-filtered entries from evicting still-visible ones.
for (const dir of ["/1", "/2", "/3", "/4", "/5", "/6"]) {
projects.open(dir)
projects.close(dir)
}
expect(projects.recentlyClosed()).toEqual(["/6", "/5", "/4", "/3", "/2", "/1"])
dispose()
})
})
test("caps recently closed history at the store limit", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const projects = createServerProjects({ scope, store, setStore })
for (let i = 1; i <= 20; i++) {
projects.open(`/p${i}`)
projects.close(`/p${i}`)
}
expect(projects.recentlyClosed()).toHaveLength(16)
expect(projects.recentlyClosed()[0]).toBe("/p20")
expect(projects.recentlyClosed().at(-1)).toBe("/p5")
dispose()
})
})
test("dedupes recently closed entries by normalized path", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const projects = createServerProjects({ scope, store, setStore })
projects.close("/repo")
projects.close("/repo/")
expect(projects.recentlyClosed()).toEqual(["/repo/"])
dispose()
})
})
})
describe("migrateCanonicalLocalServerState", () => {

View file

@ -2,12 +2,23 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createMemo } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { pathKey } from "@/utils/path-key"
import { ServerScope } from "@/utils/server-scope"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
type ServerProjectState = { projects: Record<string, StoredProject[]>; lastProject: Record<string, string> }
type ServerProjectState = {
projects: Record<string, StoredProject[]>
lastProject: Record<string, string>
recentlyClosed: Record<string, string[]>
}
const HEALTH_POLL_INTERVAL_MS = 10_000
// The store retains more history than is displayed. Consumers filter recently closed entries
// against the live project list (dropping deleted projects) and then cap the visible count via
// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
// filtered out do not evict still-visible ones from the persisted store.
const RECENTLY_CLOSED_HISTORY_LIMIT = 16
export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
export function normalizeServerUrl(input: string) {
const trimmed = input.trim()
@ -72,19 +83,42 @@ export function createServerProjects<T extends ServerProjectState>(input: {
}) {
const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
const current = () => input.store.projects[input.scope()] ?? []
const currentClosed = () => input.store.recentlyClosed?.[input.scope()] ?? []
const remove = (directory: string) => {
setStore(
"projects",
input.scope(),
current().filter((project) => project.worktree !== directory),
)
}
return {
list: current,
recentlyClosed: currentClosed,
remove,
open(directory: string) {
const scope = input.scope()
const key = pathKey(directory)
const closed = currentClosed()
if (closed.some((worktree) => pathKey(worktree) === key)) {
setStore(
"recentlyClosed",
scope,
closed.filter((worktree) => pathKey(worktree) !== key),
)
}
if (current().some((project) => project.worktree === directory)) return
setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()])
},
// User-initiated close: removes the project and records it in recently closed.
// Internal, non-user removals (e.g. sandbox/worktree normalization) should use remove().
close(directory: string) {
setStore(
"projects",
input.scope(),
current().filter((project) => project.worktree !== directory),
remove(directory)
const key = pathKey(directory)
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
0,
RECENTLY_CLOSED_HISTORY_LIMIT,
)
setStore("recentlyClosed", input.scope(), closed)
},
expand(directory: string) {
const index = current().findIndex((project) => project.worktree === directory)
@ -235,6 +269,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
list: [] as StoredServer[],
projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>,
recentlyClosed: {} as Record<string, string[]>,
}),
)

View file

@ -604,6 +604,7 @@ export const dict = {
"home.title": "Home",
"home.projects": "Projects",
"home.project.add": "Add project",
"home.recentlyClosed": "Recently closed",
"home.server.collapse": "Collapse server projects",
"home.server.expand": "Expand server projects",
"home.sessions.search.placeholder": "Search sessions",

View file

@ -270,6 +270,10 @@ export function NewHome() {
})
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
const recentlyClosed = createMemo(
() => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
)
const homedir = createMemo(() => focusedSync().data.path.home ?? "")
const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
const newSessionProject = createMemo(
() =>
@ -518,10 +522,13 @@ export function NewHome() {
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
<HomeProjectColumn
projects={projects()}
recentlyClosed={recentlyClosed()}
homedir={homedir()}
selected={selection()}
focusServer={focusServer}
selectProject={selectProject}
openNewSession={openProjectNewSession}
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
chooseProject={(conn) => void chooseProject(conn)}
editProject={editProject}
closeProject={(conn, directory) => {
@ -638,10 +645,13 @@ export function NewHome() {
function HomeProjectColumn(props: {
projects: LocalProject[]
recentlyClosed: LocalProject[]
homedir: string
selected: HomeProjectSelection
focusServer: (server: ServerConnection.Any) => void
selectProject: (server: ServerConnection.Any, directory: string) => void
openNewSession: (server: ServerConnection.Any, directory: string) => void
openRecentProject: (server: ServerConnection.Any, directory: string) => void
chooseProject: (server: ServerConnection.Any) => void
editProject: (server: ServerConnection.Any, project: LocalProject) => void
closeProject: (server: ServerConnection.Any, directory: string) => void
@ -670,8 +680,10 @@ function HomeProjectColumn(props: {
aria-label={props.language.t("home.projects")}
>
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<Show when={global.servers.list().length === 1}>
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
<Show
when={global.servers.list().length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}
>
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
<IconButtonV2
data-action="home-add-project"
@ -691,7 +703,21 @@ function HomeProjectColumn(props: {
when={global.servers.list().length > 1}
fallback={
<div class="pr-3">
<HomeProjectList {...props} server={global.servers.list()[0]!} />
<Show
when={props.projects.length > 0}
fallback={
<HomeProjectEmpty
server={global.servers.list()[0]!}
recentlyClosed={props.recentlyClosed}
homedir={props.homedir}
chooseProject={props.chooseProject}
openRecentProject={props.openRecentProject}
language={props.language}
/>
}
>
<HomeProjectList {...props} server={global.servers.list()[0]!} />
</Show>
</div>
}
>
@ -897,6 +923,79 @@ function HomeProjectList(props: {
)
}
function HomeProjectEmpty(props: {
server: ServerConnection.Any
recentlyClosed: LocalProject[]
homedir: string
chooseProject: (server: ServerConnection.Any) => void
openRecentProject: (server: ServerConnection.Any, directory: string) => void
language: ReturnType<typeof useLanguage>
}) {
const global = useGlobal()
const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
return (
<div class="flex min-w-0 flex-col gap-1">
<button
type="button"
data-action="home-add-project-row"
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
disabled={unreachable()}
onClick={() => props.chooseProject(props.server)}
>
<IconV2 name="folder-add-left" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
</button>
<Show when={props.recentlyClosed.length > 0}>
<div class="mt-3 flex h-7 min-w-0 shrink-0 items-center pl-1.5 pr-3">
<div class="text-v2-text-text-faint [font-weight:530]">{props.language.t("home.recentlyClosed")}</div>
</div>
<For each={props.recentlyClosed}>
{(project) => (
<HomeRecentlyClosedRow
project={project}
server={props.server}
homedir={props.homedir}
openRecentProject={props.openRecentProject}
language={props.language}
/>
)}
</For>
</Show>
</div>
)
}
function HomeRecentlyClosedRow(props: {
project: LocalProject
server: ServerConnection.Any
homedir: string
openRecentProject: (server: ServerConnection.Any, directory: string) => void
language: ReturnType<typeof useLanguage>
}) {
const global = useGlobal()
const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
const path = () => {
const home = props.homedir
const worktree = props.project.worktree
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
return worktree
}
return (
<TooltipV2 placement="right" value={path()}>
<button
type="button"
data-component="home-recently-closed-row"
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60`}
disabled={unreachable()}
onClick={() => props.openRecentProject(props.server, props.project.worktree)}
>
<HomeProjectAvatar project={props.project} outline />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
</button>
</TooltipV2>
)
}
function HomeProjectRow(props: {
project: LocalProject
server: ServerConnection.Any
@ -979,13 +1078,13 @@ function HomeProjectRow(props: {
)
}
function HomeProjectAvatar(props: { project: LocalProject }) {
function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) {
const name = createMemo(() => displayName(props.project))
return (
<ProjectAvatar
fallback={name()}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
src={props.outline ? undefined : getProjectAvatarSource(props.project.id, props.project.icon)}
variant={props.outline ? "outline" : getProjectAvatarVariant(props.project.icon?.color)}
/>
)
}

View file

@ -1393,14 +1393,14 @@ export function MessageTimeline(props: {
<button
type="button"
data-slot="session-title-parent"
class="min-w-0 max-w-[40%] truncate px-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={navigateParent}
>
{parentTitle()}
</button>
<span
data-slot="session-title-separator"
class="-translate-y-[0.5px] px-1 text-[11px] font-medium text-v2-text-text-faint"
class="-translate-y-[0.5px] pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/

View file

@ -3,6 +3,7 @@ import { useLanguage } from "@/context/language"
import { useLocal } from "@/context/local"
import { useSettings } from "@/context/settings"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
import { useSessionLayout } from "./session-layout"
import { createSessionOwnership } from "./session-ownership"
@ -26,9 +27,23 @@ export const useComposerCommands = () => {
const chooseModel = async () => {
const owner = sessionOwnership.capture()
const editor = document.querySelector<HTMLElement>('[data-component="prompt-input"]')
const selection = window.getSelection()
const cursor =
editor && selection?.rangeCount && editor.contains(selection.anchorNode) ? getCursorPosition(editor) : null
const restoreComposer = () => {
// Kobalte restores focus during its teardown effect; defer past it so the
// composer keeps focus and the caret returns to where the user left it.
requestAnimationFrame(() => {
const editor = document.querySelector<HTMLElement>('[data-component="prompt-input"]')
if (!editor) return
editor.focus()
if (cursor !== null) setCursorPosition(editor, cursor)
})
}
const { DialogSelectModel } = await import("@/components/dialog-select-model")
owner.run(() => {
void dialog.show(() => <DialogSelectModel model={local.model} />)
void dialog.show(() => <DialogSelectModel model={local.model} />, restoreComposer)
})
}

View file

@ -1,7 +1,6 @@
import path from "node:path"
import { pathToFileURL } from "node:url"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { type Tool } from "ai"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js"
@ -154,12 +153,19 @@ export interface ServerInstructions {
tools: string[]
}
/** An MCP tool in its native shape; consumers adapt it to their own tool format. */
export interface McpTool {
/** Shared cached definition; consumers must copy rather than mutate it. */
readonly def: MCPToolDef
readonly client: MCPClient
readonly timeout?: number
}
export interface Interface {
readonly status: () => Effect.Effect<Record<string, Status>>
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
readonly instructions: () => Effect.Effect<ServerInstructions[]>
readonly tools: () => Effect.Effect<Record<string, Tool>>
readonly defs: () => Effect.Effect<Record<string, MCPToolDef>>
readonly tools: () => Effect.Effect<Record<string, McpTool>>
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
readonly resourceTemplates: (
@ -653,7 +659,7 @@ const layer = Layer.effect(
}
const tools = Effect.fn("MCP.tools")(function* () {
const result: Record<string, Tool> = {}
const result: Record<string, McpTool> = {}
const s = yield* InstanceState.get(state)
const cfg = yield* cfgSvc.get()
@ -669,21 +675,8 @@ const layer = Layer.effect(
continue
}
const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout)
for (const mcpTool of listed) {
const key = McpCatalog.toolName(clientName, mcpTool.name)
result[key] = McpCatalog.convertTool(mcpTool, client, timeout)
}
}
return result
})
const defs = Effect.fn("MCP.defs")(function* () {
const result: Record<string, MCPToolDef> = {}
const s = yield* InstanceState.get(state)
for (const [clientName, listed] of Object.entries(s.defs)) {
if (s.status[clientName]?.status !== "connected") continue
for (const mcpTool of listed) {
result[McpCatalog.toolName(clientName, mcpTool.name)] = mcpTool
for (const def of listed) {
result[McpCatalog.toolName(clientName, def.name)] = { def, client, timeout }
}
}
return result
@ -991,7 +984,6 @@ const layer = Layer.effect(
clients,
instructions,
tools,
defs,
prompts,
resources,
resourceTemplates,

View file

@ -3,6 +3,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform"
import { MCP } from "@/mcp"
import { McpCatalog } from "@/mcp/catalog"
import { Permission } from "@/permission"
import { Tool } from "@/tool/tool"
import { ToolJsonSchema } from "@/tool/json-schema"
@ -381,7 +382,8 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
})
}
for (const [key, item] of Object.entries(yield* mcp.tools())) {
for (const [key, entry] of Object.entries(yield* mcp.tools())) {
const item = McpCatalog.convertTool(entry.def, entry.client, entry.timeout)
const execute = item.execute
if (!execute) continue

View file

@ -230,6 +230,7 @@
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
text-transform: capitalize;
color: var(--task-agent-legacy-color, var(--text-strong));
}
[data-slot="basic-tool-tool-subtitle"] {
@ -247,6 +248,57 @@
}
}
body[data-new-layout] {
[data-component="task-tool-card"] {
padding: 8px 12px 8px 10px;
border-radius: 8px;
border: 0;
border-color: transparent;
background: var(--task-agent-background, light-dark(#fafafa, rgba(250, 250, 250, 0.15)));
box-shadow: inset 0 0 0 0.5px var(--task-agent-border, var(--v2-border-border-base));
[data-component="task-tool-spinner"],
[data-component="task-tool-title"] {
color: var(--task-agent-color, var(--text-strong));
}
[data-component="task-tool-title"] {
height: 20px;
font-family: var(--v2-font-family-sans, "Inter", sans-serif);
font-style: normal;
font-weight: 530;
font-size: 13px;
line-height: 20px;
letter-spacing: -0.04px;
flex: none;
flex-grow: 0;
}
[data-slot="basic-tool-tool-subtitle"] {
font-family: var(--v2-font-family-sans, "Inter", sans-serif);
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 20px;
letter-spacing: -0.04px;
color: light-dark(var(--v2-text-text-base), #fafafa);
font-variation-settings: "slnt" 0;
flex: none;
flex-grow: 0;
}
[data-component="session-progress-indicator-v2"] {
color: var(--task-agent-color, light-dark(var(--v2-text-text-base), #ffffff));
}
&:hover,
&:focus-visible {
border-color: transparent;
background: var(--task-agent-background, light-dark(#fafafa, rgba(250, 250, 250, 0.15)));
}
}
}
body:not([data-new-layout]) {
[data-component="tool-trigger"] {
[data-slot="basic-tool-tool-spinner"] {

View file

@ -62,6 +62,7 @@ import { animate } from "motion"
import { useLocation } from "@solidjs/router"
import { attached, inline, kind } from "./message-file"
import { readPartText } from "./message-part-text"
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
async function writeClipboard(text: string): Promise<boolean> {
const body = typeof document === "undefined" ? undefined : document.body
@ -370,6 +371,44 @@ const agentTones: Record<string, string> = {
plan: "var(--icon-agent-plan-base)",
}
const v2AgentTones: Record<string, { color: string; border: string; background: string }> = {
build: {
color: "var(--v2-agent-build-solid)",
border: "var(--v2-agent-build-border)",
background: "var(--v2-agent-build-background)",
},
explore: {
color: "var(--v2-agent-explore-solid)",
border: "var(--v2-agent-explore-border)",
background: "var(--v2-agent-explore-background)",
},
plan: {
color: "var(--v2-agent-plan-solid)",
border: "var(--v2-agent-plan-border)",
background: "var(--v2-agent-plan-background)",
},
}
const agentThemeColors: Record<string, string> = {
primary: "var(--text-interactive-base)",
secondary: "var(--text-base)",
accent: "var(--icon-info-base)",
success: "var(--icon-success-base)",
warning: "var(--icon-warning-base)",
error: "var(--icon-critical-base)",
info: "var(--icon-info-base)",
}
const v2AgentThemeColors: Record<string, string> = {
primary: "var(--v2-text-text-accent)",
secondary: "var(--v2-text-text-muted)",
accent: "var(--v2-icon-icon-accent)",
success: "var(--v2-state-fg-success)",
warning: "var(--v2-state-fg-warning)",
error: "var(--v2-state-fg-danger)",
info: "var(--v2-state-fg-info)",
}
const agentPalette = [
"var(--icon-agent-ask-base)",
"var(--icon-agent-build-base)",
@ -394,16 +433,31 @@ function tone(name: string) {
function taskAgent(
raw: unknown,
list?: readonly { name: string; color?: string }[],
): { name?: string; color?: string } {
): { name?: string; color?: string; v2Color?: string; border?: string; background?: string } {
if (typeof raw !== "string" || !raw) return {}
const key = raw.toLowerCase()
const item = list?.find((entry) => entry.name === raw || entry.name.toLowerCase() === key)
const v2Tone = item?.color ? undefined : v2AgentTones[key]
const color = agentColor(item?.color, agentThemeColors) ?? agentTones[key] ?? tone(key)
const v2Color = agentColor(item?.color, v2AgentThemeColors) ?? v2Tone?.color ?? color
return {
name: item?.name ?? `${raw[0]!.toUpperCase()}${raw.slice(1)}`,
color: item?.color ?? agentTones[key] ?? tone(key),
color,
v2Color,
border: v2Tone?.border ?? `color-mix(in srgb, ${v2Color} 48%, transparent)`,
background: v2Tone?.background ?? `color-mix(in srgb, ${v2Color} 12%, transparent)`,
}
}
function agentColor(value: string | undefined, themeColors: Record<string, string>) {
if (!value) return
return themeColors[value] ?? value
}
function newLayout() {
return typeof document !== "undefined" && document.body.hasAttribute("data-new-layout")
}
function webSearchProviderLabel(provider: unknown) {
if (provider === "parallel") return "Parallel Web Search"
if (provider === "exa") return "Exa Web Search"
@ -1882,6 +1936,9 @@ ToolRegistry.register({
const agent = createMemo(() => taskAgent(props.input.subagent_type, data.store.agent))
const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default"))
const tone = createMemo(() => agent().color)
const v2Tone = createMemo(() => agent().v2Color)
const border = createMemo(() => agent().border)
const background = createMemo(() => agent().background)
const subtitle = createMemo(() => {
const value =
typeof props.input.description === "string" && props.input.description
@ -1921,17 +1978,27 @@ ToolRegistry.register({
}
const trigger = () => (
<div data-component="task-tool-card">
<div
data-component="task-tool-card"
style={{
"--task-agent-color": v2Tone(),
"--task-agent-legacy-color": tone(),
"--task-agent-border": border(),
"--task-agent-background": background(),
}}
>
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<Show when={running()}>
<span data-component="task-tool-spinner" style={{ color: tone() ?? "var(--icon-interactive-base)" }}>
<Spinner />
<Show when={newLayout()} fallback={<Spinner />}>
<SessionProgressIndicatorV2
style={{ color: v2Tone() ?? "light-dark(var(--v2-text-text-base), #ffffff)" }}
/>
</Show>
</span>
</Show>
<span data-component="task-tool-title" style={{ color: tone() ?? "var(--text-strong)" }}>
{title()}
</span>
<span data-component="task-tool-title">{title()}</span>
<Show when={subtitle()}>
<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
</Show>

View file

@ -281,6 +281,15 @@
--icon-agent-docs-base: #fcb239;
--icon-agent-ask-base: #2090f5;
--icon-agent-build-base: #034cff;
--v2-agent-plan-solid: var(--v2-pink-900);
--v2-agent-plan-border: rgba(200, 61, 139, 0.5);
--v2-agent-plan-background: rgba(253, 236, 243, 0.33);
--v2-agent-build-solid: var(--v2-blue-900);
--v2-agent-build-border: rgba(59, 92, 246, 0.5);
--v2-agent-build-background: rgba(236, 241, 254, 0.33);
--v2-agent-explore-solid: var(--v2-yellow-900);
--v2-agent-explore-border: rgba(142, 114, 49, 0.5);
--v2-agent-explore-background: rgba(254, 250, 236, 0.33);
--icon-on-success-base: rgba(18, 201, 5, 0.9);
--icon-on-success-hover: rgba(45, 186, 38, 0.9);
--icon-on-success-selected: rgba(7, 137, 1, 0.9);
@ -541,6 +550,15 @@
--icon-agent-docs-base: #fbb73c;
--icon-agent-ask-base: #2090f5;
--icon-agent-build-base: #9dbefe;
--v2-agent-plan-solid: var(--v2-pink-400);
--v2-agent-plan-border: rgba(250, 188, 216, 0.5);
--v2-agent-plan-background: rgba(247, 213, 228, 0.1);
--v2-agent-build-solid: var(--v2-blue-400);
--v2-agent-build-border: rgba(215, 226, 252, 0.5);
--v2-agent-build-background: rgba(215, 226, 252, 0.1);
--v2-agent-explore-solid: var(--v2-yellow-400);
--v2-agent-explore-border: rgba(247, 229, 181, 0.5);
--v2-agent-explore-background: rgba(252, 239, 208, 0.1);
--icon-on-success-base: rgba(18, 201, 5, 0.9);
--icon-on-success-hover: rgba(53, 192, 45, 0.9);
--icon-on-success-selected: rgba(77, 225, 68, 0.9);

View file

@ -3,6 +3,30 @@ import { V2_AVATAR_DARK, V2_AVATAR_LIGHT } from "./avatar"
const ref = (name: string): V2ColorValue => `var(--${name})`
const lightAgentTokens: Record<string, V2ColorValue> = {
"v2-agent-plan-solid": ref("v2-pink-900"),
"v2-agent-plan-border": "rgba(200, 61, 139, 0.5)",
"v2-agent-plan-background": "rgba(253, 236, 243, 0.33)",
"v2-agent-build-solid": ref("v2-blue-900"),
"v2-agent-build-border": "rgba(59, 92, 246, 0.5)",
"v2-agent-build-background": "rgba(236, 241, 254, 0.33)",
"v2-agent-explore-solid": ref("v2-yellow-900"),
"v2-agent-explore-border": "rgba(142, 114, 49, 0.5)",
"v2-agent-explore-background": "rgba(254, 250, 236, 0.33)",
}
const darkAgentTokens: Record<string, V2ColorValue> = {
"v2-agent-plan-solid": ref("v2-pink-400"),
"v2-agent-plan-border": "rgba(250, 188, 216, 0.5)",
"v2-agent-plan-background": "rgba(247, 213, 228, 0.1)",
"v2-agent-build-solid": ref("v2-blue-400"),
"v2-agent-build-border": "rgba(215, 226, 252, 0.5)",
"v2-agent-build-background": "rgba(215, 226, 252, 0.1)",
"v2-agent-explore-solid": ref("v2-yellow-400"),
"v2-agent-explore-border": "rgba(247, 229, 181, 0.5)",
"v2-agent-explore-background": "rgba(252, 239, 208, 0.1)",
}
const light: Record<string, V2ColorValue> = {
"v2-background-bg-base": ref("v2-grey-100"),
"v2-background-bg-deep": ref("v2-grey-200"),
@ -46,6 +70,7 @@ const light: Record<string, V2ColorValue> = {
"v2-state-bg-info": ref("v2-blue-100"),
"v2-state-fg-info": ref("v2-blue-800"),
"v2-state-border-info": ref("v2-blue-300"),
...lightAgentTokens,
...V2_AVATAR_LIGHT,
"v2-elevation-raised":
"0px 2px 4px 0px var(--v2-alpha-dark-4), 0px 1px 2px -1px var(--v2-alpha-dark-8), 0px 0px 0px 0.5px var(--v2-alpha-dark-12), 0px 0px 0px 0px var(--v2-alpha-dark-0)",
@ -110,6 +135,7 @@ const dark: Record<string, V2ColorValue> = {
"v2-state-bg-info": ref("v2-blue-1200"),
"v2-state-fg-info": ref("v2-blue-500"),
"v2-state-border-info": ref("v2-blue-900"),
...darkAgentTokens,
...V2_AVATAR_DARK,
"v2-elevation-raised":
"0px 2px 4px 0px var(--v2-alpha-dark-30), 0px 1px 2px 0px var(--v2-alpha-dark-30), 0px 0px 0px 0.5px var(--v2-alpha-light-16), 0px -0.5px 0px 0px var(--v2-alpha-light-6)",

View file

@ -81,6 +81,13 @@
--project-avatar-border: var(--v2-avatar-border-gray);
}
[data-slot="project-avatar-surface"][data-variant="outline"] {
--project-avatar-bg: var(--v2-background-bg-base);
--project-avatar-border: var(--v2-border-border-strong);
color: var(--v2-text-text-muted);
text-shadow: none;
}
[data-slot="project-avatar-surface"][data-has-image] {
background: var(--project-avatar-bg);
}

View file

@ -11,6 +11,7 @@ Saturated 16px project avatar with color variants and optional unread dot.
### Variants
- Color: orange, yellow, cyan, green, red, pink, blue, purple, gray.
- Outline: neutral, muted style for de-emphasized projects (e.g. recently closed).
- Image vs initial content state.
- Unread dot with corner mask when \`unread\` is set.
@ -33,7 +34,7 @@ export default {
argTypes: {
variant: {
control: "select",
options: [...PROJECT_AVATAR_VARIANTS],
options: [...PROJECT_AVATAR_VARIANTS, "outline"],
},
},
args: {
@ -62,6 +63,13 @@ export const AllVariants = {
),
}
export const Outline = {
args: {
fallback: "O",
variant: "outline",
},
}
export const Unread = {
args: {
fallback: "O",

View file

@ -26,10 +26,13 @@ export const PROJECT_AVATAR_VARIANTS = [
export type ProjectAvatarVariant = (typeof PROJECT_AVATAR_VARIANTS)[number]
// "outline" is a neutral, muted style (e.g. recently closed projects) and is not part of the color rotation.
export type ProjectAvatarStyle = ProjectAvatarVariant | "outline"
export interface ProjectAvatarProps extends ComponentProps<"div"> {
fallback: string
src?: string
variant?: ProjectAvatarVariant
variant?: ProjectAvatarStyle
unread?: boolean
}

View file

@ -77,6 +77,16 @@
--v2-state-fg-info: var(--v2-blue-800);
--v2-state-border-info: var(--v2-blue-300);
--v2-agent-plan-solid: var(--v2-pink-900);
--v2-agent-plan-border: rgba(200, 61, 139, 0.5);
--v2-agent-plan-background: rgba(253, 236, 243, 0.33);
--v2-agent-build-solid: var(--v2-blue-900);
--v2-agent-build-border: rgba(59, 92, 246, 0.5);
--v2-agent-build-background: rgba(236, 241, 254, 0.33);
--v2-agent-explore-solid: var(--v2-yellow-900);
--v2-agent-explore-border: rgba(142, 114, 49, 0.5);
--v2-agent-explore-background: rgba(254, 250, 236, 0.33);
/* ── Project avatar (fixed; theme-independent) ── */
--v2-avatar-fg: #ffffffff;
--v2-avatar-bg-orange: #ee7330ff;
@ -193,6 +203,16 @@
--v2-state-fg-info: var(--v2-blue-500);
--v2-state-border-info: var(--v2-blue-900);
--v2-agent-plan-solid: var(--v2-pink-400);
--v2-agent-plan-border: rgba(250, 188, 216, 0.5);
--v2-agent-plan-background: rgba(247, 213, 228, 0.1);
--v2-agent-build-solid: var(--v2-blue-400);
--v2-agent-build-border: rgba(215, 226, 252, 0.5);
--v2-agent-build-background: rgba(215, 226, 252, 0.1);
--v2-agent-explore-solid: var(--v2-yellow-400);
--v2-agent-explore-border: rgba(247, 229, 181, 0.5);
--v2-agent-explore-background: rgba(252, 239, 208, 0.1);
--v2-elevation-raised:
0px 2px 4px 0px var(--v2-alpha-dark-30),
0px 1px 2px 0px var(--v2-alpha-dark-30),
@ -296,6 +316,16 @@
--v2-state-fg-info: var(--v2-blue-800);
--v2-state-border-info: var(--v2-blue-300);
--v2-agent-plan-solid: var(--v2-pink-900);
--v2-agent-plan-border: rgba(200, 61, 139, 0.5);
--v2-agent-plan-background: rgba(253, 236, 243, 0.33);
--v2-agent-build-solid: var(--v2-blue-900);
--v2-agent-build-border: rgba(59, 92, 246, 0.5);
--v2-agent-build-background: rgba(236, 241, 254, 0.33);
--v2-agent-explore-solid: var(--v2-yellow-900);
--v2-agent-explore-border: rgba(142, 114, 49, 0.5);
--v2-agent-explore-background: rgba(254, 250, 236, 0.33);
--v2-elevation-raised:
0px 2px 4px 0px var(--v2-alpha-dark-4), 0px 1px 2px -1px var(--v2-alpha-dark-8),
0px 0px 0px 0.5px var(--v2-alpha-dark-12), 0px 0px 0px 0px var(--v2-alpha-dark-0);
@ -406,6 +436,16 @@
--v2-state-fg-info: var(--v2-blue-500);
--v2-state-border-info: var(--v2-blue-900);
--v2-agent-plan-solid: var(--v2-pink-400);
--v2-agent-plan-border: rgba(250, 188, 216, 0.5);
--v2-agent-plan-background: rgba(247, 213, 228, 0.1);
--v2-agent-build-solid: var(--v2-blue-400);
--v2-agent-build-border: rgba(215, 226, 252, 0.5);
--v2-agent-build-background: rgba(215, 226, 252, 0.1);
--v2-agent-explore-solid: var(--v2-yellow-400);
--v2-agent-explore-border: rgba(247, 229, 181, 0.5);
--v2-agent-explore-background: rgba(252, 239, 208, 0.1);
--v2-avatar-fg: #ffffffff;
--v2-avatar-bg-orange: #723d22ff;
--v2-avatar-border-orange: #ff8648ff;