Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4871a3b418 | ||
|
|
bd9ca72f3c | ||
|
|
a1dbebb828 | ||
|
|
e59ccfa39d | ||
|
|
5bf12ab783 | ||
|
|
cf5ced6a18 | ||
|
|
c630841e8a | ||
|
|
e7706670a9 | ||
|
|
965c751522 | ||
|
|
24bdd3c9fb | ||
|
|
01f0319192 | ||
|
|
517e6c9aa4 | ||
|
|
a4a9ea4ab0 | ||
|
|
eaa272ef7f | ||
|
|
70b636a360 | ||
|
|
a8fd0159be | ||
|
|
342436dfc4 | ||
|
|
77a462c930 | ||
|
|
9965d385de | ||
|
|
f0f1e51c5c |
@@ -26,7 +26,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.64",
|
"@ai-sdk/anthropic": "3.0.64",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -189,7 +189,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -222,7 +222,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop-electron": {
|
"packages/desktop-electron": {
|
||||||
"name": "@opencode-ai/desktop-electron",
|
"name": "@opencode-ai/desktop-electron",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -254,7 +254,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
@@ -283,7 +283,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -299,7 +299,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -430,7 +430,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -464,7 +464,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -479,7 +479,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -514,7 +514,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -562,7 +562,7 @@
|
|||||||
},
|
},
|
||||||
"packages/util": {
|
"packages/util": {
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -573,7 +573,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { seedSessionTask, withSession } from "../actions"
|
import { seedSessionTask, withSession } from "../actions"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { inputMatch } from "../prompt/mock"
|
import { inputMatch } from "../prompt/mock"
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
|
|
||||||
test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => {
|
test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
@@ -30,15 +29,33 @@ test("task tool child-session link does not trigger stale show errors", async ({
|
|||||||
|
|
||||||
await project.gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
const link = page
|
const header = page.locator("[data-session-title]")
|
||||||
.locator("a.subagent-link")
|
await expect(header.getByRole("button", { name: "More options" })).toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
|
const card = page
|
||||||
|
.locator('[data-component="task-tool-card"]')
|
||||||
.filter({ hasText: /open child session/i })
|
.filter({ hasText: /open child session/i })
|
||||||
.first()
|
.first()
|
||||||
await expect(link).toBeVisible({ timeout: 30_000 })
|
await expect(card).toBeVisible({ timeout: 30_000 })
|
||||||
await link.click()
|
await card.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
||||||
await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
|
await expect(header.locator('[data-slot="session-title-parent"]')).toHaveText(session.title)
|
||||||
|
await expect(header.locator('[data-slot="session-title-child"]')).toHaveText(taskInput.description)
|
||||||
|
await expect(header.locator('[data-slot="session-title-separator"]')).toHaveText("/")
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() =>
|
||||||
|
header.locator('[data-slot="session-title-separator"]').evaluate((el) => ({
|
||||||
|
left: getComputedStyle(el).paddingLeft,
|
||||||
|
right: getComputedStyle(el).paddingRight,
|
||||||
|
})),
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toEqual({ left: "8px", right: "8px" })
|
||||||
|
await expect(header.getByRole("button", { name: "More options" })).toHaveCount(0)
|
||||||
|
await expect(page.getByText("Subagent sessions cannot be prompted.")).toBeVisible({ timeout: 30_000 })
|
||||||
|
await expect(page.getByRole("button", { name: "Back to main session." })).toBeVisible({ timeout: 30_000 })
|
||||||
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
|
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -238,6 +238,8 @@ export const dict = {
|
|||||||
"prompt.mode.shell": "Shell",
|
"prompt.mode.shell": "Shell",
|
||||||
"prompt.mode.normal": "Prompt",
|
"prompt.mode.normal": "Prompt",
|
||||||
"prompt.mode.shell.exit": "esc to exit",
|
"prompt.mode.shell.exit": "esc to exit",
|
||||||
|
"session.child.promptDisabled": "Subagent sessions cannot be prompted.",
|
||||||
|
"session.child.backToParent": "Back to main session.",
|
||||||
|
|
||||||
"prompt.example.1": "Fix a TODO in the codebase",
|
"prompt.example.1": "Fix a TODO in the codebase",
|
||||||
"prompt.example.2": "What is the tech stack of this project?",
|
"prompt.example.2": "What is the tech stack of this project?",
|
||||||
|
|||||||
@@ -1,6 +1,46 @@
|
|||||||
@import "@opencode-ai/ui/styles/tailwind";
|
@import "@opencode-ai/ui/styles/tailwind";
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
@keyframes session-progress-whip {
|
||||||
|
0% {
|
||||||
|
clip-path: inset(0 100% 0 0 round 999px);
|
||||||
|
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
48% {
|
||||||
|
clip-path: inset(0 0 0 0 round 999px);
|
||||||
|
animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
clip-path: inset(0 0 0 100% round 999px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="session-progress"] {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto;
|
||||||
|
height: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 220ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="session-progress"][data-state="hiding"] {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="session-progress-bar"] {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--session-progress-color);
|
||||||
|
clip-path: inset(0 100% 0 0 round 999px);
|
||||||
|
animation: session-progress-whip var(--session-progress-ms, 1800ms) infinite;
|
||||||
|
will-change: clip-path;
|
||||||
|
}
|
||||||
|
|
||||||
[data-component="getting-started"] {
|
[data-component="getting-started"] {
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
container-name: getting-started;
|
container-name: getting-started;
|
||||||
|
|||||||
@@ -150,7 +150,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
autoselect: !initialDirectory,
|
autoselect: !initialDirectory,
|
||||||
busyWorkspaces: {} as Record<string, boolean>,
|
busyWorkspaces: {} as Record<string, boolean>,
|
||||||
hoverSession: undefined as string | undefined,
|
|
||||||
hoverProject: undefined as string | undefined,
|
hoverProject: undefined as string | undefined,
|
||||||
scrollSessionKey: undefined as string | undefined,
|
scrollSessionKey: undefined as string | undefined,
|
||||||
nav: undefined as HTMLElement | undefined,
|
nav: undefined as HTMLElement | undefined,
|
||||||
@@ -194,7 +193,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
onActivate: (directory) => {
|
onActivate: (directory) => {
|
||||||
globalSync.child(directory)
|
globalSync.child(directory)
|
||||||
setState("hoverProject", directory)
|
setState("hoverProject", directory)
|
||||||
setState("hoverSession", undefined)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -231,7 +229,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
aim.reset()
|
aim.reset()
|
||||||
}
|
}
|
||||||
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
|
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
|
||||||
const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
|
|
||||||
|
|
||||||
const disarm = () => {
|
const disarm = () => {
|
||||||
if (navLeave.current === undefined) return
|
if (navLeave.current === undefined) return
|
||||||
@@ -241,7 +238,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
disarm()
|
disarm()
|
||||||
setState("hoverSession", undefined)
|
|
||||||
setHoverProject(undefined)
|
setHoverProject(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,7 +248,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
navLeave.current = window.setTimeout(() => {
|
navLeave.current = window.setTimeout(() => {
|
||||||
navLeave.current = undefined
|
navLeave.current = undefined
|
||||||
setHoverProject(undefined)
|
setHoverProject(undefined)
|
||||||
setState("hoverSession", undefined)
|
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1972,9 +1967,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
navList: currentSessions,
|
navList: currentSessions,
|
||||||
sidebarExpanded,
|
sidebarExpanded,
|
||||||
sidebarHovering,
|
sidebarHovering,
|
||||||
nav: () => state.nav,
|
|
||||||
hoverSession: () => state.hoverSession,
|
|
||||||
setHoverSession,
|
|
||||||
clearHoverProjectSoon,
|
clearHoverProjectSoon,
|
||||||
prefetchSession,
|
prefetchSession,
|
||||||
archiveSession,
|
archiveSession,
|
||||||
@@ -2003,7 +1995,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
sidebarOpened: () => layout.sidebar.opened(),
|
sidebarOpened: () => layout.sidebar.opened(),
|
||||||
sidebarHovering,
|
sidebarHovering,
|
||||||
hoverProject: () => state.hoverProject,
|
hoverProject: () => state.hoverProject,
|
||||||
nav: () => state.nav,
|
|
||||||
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
|
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
|
||||||
onProjectMouseLeave: (worktree) => aim.leave(worktree),
|
onProjectMouseLeave: (worktree) => aim.leave(worktree),
|
||||||
onProjectFocus: (worktree) => aim.activate(worktree),
|
onProjectFocus: (worktree) => aim.activate(worktree),
|
||||||
@@ -2022,15 +2013,10 @@ export default function Layout(props: ParentProps) {
|
|||||||
sessionProps: {
|
sessionProps: {
|
||||||
navList: currentSessions,
|
navList: currentSessions,
|
||||||
sidebarExpanded,
|
sidebarExpanded,
|
||||||
sidebarHovering,
|
|
||||||
nav: () => state.nav,
|
|
||||||
hoverSession: () => state.hoverSession,
|
|
||||||
setHoverSession,
|
|
||||||
clearHoverProjectSoon,
|
clearHoverProjectSoon,
|
||||||
prefetchSession,
|
prefetchSession,
|
||||||
archiveSession,
|
archiveSession,
|
||||||
},
|
},
|
||||||
setHoverSession,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarPanel = (panelProps: {
|
const SidebarPanel = (panelProps: {
|
||||||
@@ -2041,7 +2027,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
const project = panelProps.project
|
const project = panelProps.project
|
||||||
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
|
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
|
||||||
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
|
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
|
||||||
const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
|
|
||||||
const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
|
const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
|
||||||
const projectName = createMemo(() => {
|
const projectName = createMemo(() => {
|
||||||
const item = project()
|
const item = project()
|
||||||
@@ -2243,7 +2228,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
project={project()!}
|
project={project()!}
|
||||||
sortNow={sortNow}
|
sortNow={sortNow}
|
||||||
mobile={panelProps.mobile}
|
mobile={panelProps.mobile}
|
||||||
popover={popover()}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -2288,7 +2272,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
project={project()!}
|
project={project()!}
|
||||||
sortNow={sortNow}
|
sortNow={sortNow}
|
||||||
mobile={panelProps.mobile}
|
mobile={panelProps.mobile}
|
||||||
popover={popover()}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "./deep-links"
|
} from "./deep-links"
|
||||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import {
|
import {
|
||||||
|
childSessionOnPath,
|
||||||
displayName,
|
displayName,
|
||||||
effectiveWorkspaceOrder,
|
effectiveWorkspaceOrder,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
@@ -198,6 +199,19 @@ describe("layout workspace helpers", () => {
|
|||||||
expect(result?.id).toBe("root")
|
expect(result?.id).toBe("root")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("finds the direct child on the active session path", () => {
|
||||||
|
const list = [
|
||||||
|
session({ id: "root", directory: "/workspace" }),
|
||||||
|
session({ id: "child", directory: "/workspace", parentID: "root" }),
|
||||||
|
session({ id: "leaf", directory: "/workspace", parentID: "child" }),
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(childSessionOnPath(list, "root", "leaf")?.id).toBe("child")
|
||||||
|
expect(childSessionOnPath(list, "child", "leaf")?.id).toBe("leaf")
|
||||||
|
expect(childSessionOnPath(list, "root", "root")).toBeUndefined()
|
||||||
|
expect(childSessionOnPath(list, "root", "other")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
test("formats fallback project display name", () => {
|
test("formats fallback project display name", () => {
|
||||||
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
|
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
|
||||||
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
|
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
|
||||||
|
|||||||
@@ -46,18 +46,17 @@ export function hasProjectPermissions<T>(
|
|||||||
return Object.values(request ?? {}).some((list) => list?.some(include))
|
return Object.values(request ?? {}).some((list) => list?.some(include))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const childMapByParent = (sessions: Session[] | undefined) => {
|
export const childSessionOnPath = (sessions: Session[] | undefined, rootID: string, activeID?: string) => {
|
||||||
const map = new Map<string, string[]>()
|
if (!activeID || activeID === rootID) return
|
||||||
for (const session of sessions ?? []) {
|
const map = new Map((sessions ?? []).map((session) => [session.id, session]))
|
||||||
if (!session.parentID) continue
|
let id = activeID
|
||||||
const existing = map.get(session.parentID)
|
|
||||||
if (existing) {
|
while (id) {
|
||||||
existing.push(session.id)
|
const session = map.get(id)
|
||||||
continue
|
if (!session?.parentID) return
|
||||||
}
|
if (session.parentID === rootID) return session
|
||||||
map.set(session.parentID, [session.id])
|
id = session.parentID
|
||||||
}
|
}
|
||||||
return map
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const displayName = (project: { name?: string; worktree: string }) =>
|
export const displayName = (project: { name?: string; worktree: string }) =>
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import type { Message, Session, TextPart, UserMessage } from "@opencode-ai/sdk/v2/client"
|
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||||
import { HoverCard } from "@opencode-ai/ui/hover-card"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { MessageNav } from "@opencode-ai/ui/message-nav"
|
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
import { A, useParams } from "@solidjs/router"
|
||||||
import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
|
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
||||||
@@ -18,7 +15,7 @@ import { usePermission } from "@/context/permission"
|
|||||||
import { messageAgentColor } from "@/utils/agent"
|
import { messageAgentColor } from "@/utils/agent"
|
||||||
import { sessionTitle } from "@/utils/session-title"
|
import { sessionTitle } from "@/utils/session-title"
|
||||||
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
||||||
import { hasProjectPermissions } from "./helpers"
|
import { childSessionOnPath, hasProjectPermissions } from "./helpers"
|
||||||
|
|
||||||
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
||||||
|
|
||||||
@@ -39,6 +36,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
|
|||||||
)
|
)
|
||||||
const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0))
|
const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0))
|
||||||
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
|
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
|
||||||
<div class="size-full rounded overflow-clip">
|
<div class="size-full rounded overflow-clip">
|
||||||
@@ -73,13 +71,10 @@ export type SessionItemProps = {
|
|||||||
slug: string
|
slug: string
|
||||||
mobile?: boolean
|
mobile?: boolean
|
||||||
dense?: boolean
|
dense?: boolean
|
||||||
popover?: boolean
|
showTooltip?: boolean
|
||||||
children: Map<string, string[]>
|
showChild?: boolean
|
||||||
|
level?: number
|
||||||
sidebarExpanded: Accessor<boolean>
|
sidebarExpanded: Accessor<boolean>
|
||||||
sidebarHovering: Accessor<boolean>
|
|
||||||
nav: Accessor<HTMLElement | undefined>
|
|
||||||
hoverSession: Accessor<string | undefined>
|
|
||||||
setHoverSession: (id: string | undefined) => void
|
|
||||||
clearHoverProjectSoon: () => void
|
clearHoverProjectSoon: () => void
|
||||||
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
||||||
archiveSession: (session: Session) => Promise<void>
|
archiveSession: (session: Session) => Promise<void>
|
||||||
@@ -95,116 +90,52 @@ const SessionRow = (props: {
|
|||||||
hasPermissions: Accessor<boolean>
|
hasPermissions: Accessor<boolean>
|
||||||
hasError: Accessor<boolean>
|
hasError: Accessor<boolean>
|
||||||
unseenCount: Accessor<number>
|
unseenCount: Accessor<number>
|
||||||
setHoverSession: (id: string | undefined) => void
|
|
||||||
clearHoverProjectSoon: () => void
|
clearHoverProjectSoon: () => void
|
||||||
sidebarOpened: Accessor<boolean>
|
sidebarOpened: Accessor<boolean>
|
||||||
warmHover: () => void
|
|
||||||
warmPress: () => void
|
warmPress: () => void
|
||||||
warmFocus: () => void
|
warmFocus: () => void
|
||||||
cancelHoverPrefetch: () => void
|
}): JSX.Element => {
|
||||||
}) => {
|
|
||||||
const title = () => sessionTitle(props.session.title)
|
const title = () => sessionTitle(props.session.title)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<A
|
<A
|
||||||
href={`/${props.slug}/session/${props.session.id}`}
|
href={`/${props.slug}/session/${props.session.id}`}
|
||||||
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||||
onPointerDown={props.warmPress}
|
onPointerDown={props.warmPress}
|
||||||
onPointerEnter={props.warmHover}
|
|
||||||
onPointerLeave={props.cancelHoverPrefetch}
|
|
||||||
onFocus={props.warmFocus}
|
onFocus={props.warmFocus}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.setHoverSession(undefined)
|
|
||||||
if (props.sidebarOpened()) return
|
if (props.sidebarOpened()) return
|
||||||
props.clearHoverProjectSoon()
|
props.clearHoverProjectSoon()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<Show when={props.isWorking() || props.hasPermissions() || props.hasError() || props.unseenCount() > 0}>
|
||||||
class="shrink-0 size-6 flex items-center justify-center"
|
<div
|
||||||
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
class="shrink-0 size-6 flex items-center justify-center"
|
||||||
>
|
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
||||||
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
|
>
|
||||||
<Match when={props.isWorking()}>
|
<Switch>
|
||||||
<Spinner class="size-[15px]" />
|
<Match when={props.isWorking()}>
|
||||||
</Match>
|
<Spinner class="size-[15px]" />
|
||||||
<Match when={props.hasPermissions()}>
|
</Match>
|
||||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
<Match when={props.hasPermissions()}>
|
||||||
</Match>
|
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||||
<Match when={props.hasError()}>
|
</Match>
|
||||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
<Match when={props.hasError()}>
|
||||||
</Match>
|
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||||
<Match when={props.unseenCount() > 0}>
|
</Match>
|
||||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
<Match when={props.unseenCount() > 0}>
|
||||||
</Match>
|
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||||
</Switch>
|
</Match>
|
||||||
</div>
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span>
|
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span>
|
||||||
</A>
|
</A>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const SessionHoverPreview = (props: {
|
|
||||||
mobile?: boolean
|
|
||||||
nav: Accessor<HTMLElement | undefined>
|
|
||||||
hoverSession: Accessor<string | undefined>
|
|
||||||
session: Session
|
|
||||||
sidebarHovering: Accessor<boolean>
|
|
||||||
hoverReady: Accessor<boolean>
|
|
||||||
hoverMessages: Accessor<UserMessage[] | undefined>
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
isActive: Accessor<boolean>
|
|
||||||
slug: string
|
|
||||||
setHoverSession: (id: string | undefined) => void
|
|
||||||
messageLabel: (message: Message) => string | undefined
|
|
||||||
onMessageSelect: (message: Message) => void
|
|
||||||
trigger: JSX.Element
|
|
||||||
}): JSX.Element => {
|
|
||||||
let ref: HTMLDivElement | undefined
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HoverCard
|
|
||||||
openDelay={1000}
|
|
||||||
closeDelay={props.sidebarHovering() ? 600 : 0}
|
|
||||||
placement="right-start"
|
|
||||||
gutter={16}
|
|
||||||
shift={-2}
|
|
||||||
trigger={
|
|
||||||
<div ref={ref} class="min-w-0 w-full">
|
|
||||||
{props.trigger}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
open={props.hoverSession() === props.session.id}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
props.setHoverSession(undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!ref?.matches(":hover")) return
|
|
||||||
props.setHoverSession(props.session.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={props.hoverReady()}
|
|
||||||
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
|
|
||||||
>
|
|
||||||
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
|
|
||||||
<MessageNav
|
|
||||||
messages={props.hoverMessages() ?? []}
|
|
||||||
current={undefined}
|
|
||||||
getLabel={props.messageLabel}
|
|
||||||
onMessageSelect={props.onMessageSelect}
|
|
||||||
size="normal"
|
|
||||||
class="w-60"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</HoverCard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const navigate = useNavigate()
|
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
@@ -234,18 +165,13 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const tint = createMemo(() => {
|
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent))
|
||||||
return messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)
|
const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded()))
|
||||||
|
const currentChild = createMemo(() => {
|
||||||
|
if (!props.showChild) return
|
||||||
|
return childSessionOnPath(sessionStore.session, props.session.id, params.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
const hoverMessages = createMemo(() =>
|
|
||||||
sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"),
|
|
||||||
)
|
|
||||||
const hoverReady = createMemo(() => hoverMessages() !== undefined)
|
|
||||||
const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
|
|
||||||
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
|
|
||||||
const isActive = createMemo(() => props.session.id === params.id)
|
|
||||||
|
|
||||||
const warm = (span: number, priority: "high" | "low") => {
|
const warm = (span: number, priority: "high" | "low") => {
|
||||||
const nav = props.navList?.()
|
const nav = props.navList?.()
|
||||||
const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory)
|
const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory)
|
||||||
@@ -266,30 +192,6 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hoverPrefetch = {
|
|
||||||
current: undefined as ReturnType<typeof setTimeout> | undefined,
|
|
||||||
}
|
|
||||||
const cancelHoverPrefetch = () => {
|
|
||||||
if (hoverPrefetch.current === undefined) return
|
|
||||||
clearTimeout(hoverPrefetch.current)
|
|
||||||
hoverPrefetch.current = undefined
|
|
||||||
}
|
|
||||||
const scheduleHoverPrefetch = () => {
|
|
||||||
warm(1, "high")
|
|
||||||
if (hoverPrefetch.current !== undefined) return
|
|
||||||
hoverPrefetch.current = setTimeout(() => {
|
|
||||||
hoverPrefetch.current = undefined
|
|
||||||
warm(2, "low")
|
|
||||||
}, 80)
|
|
||||||
}
|
|
||||||
|
|
||||||
onCleanup(cancelHoverPrefetch)
|
|
||||||
|
|
||||||
const messageLabel = (message: Message) => {
|
|
||||||
const parts = sessionStore.part[message.id] ?? []
|
|
||||||
const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
|
|
||||||
return text?.text
|
|
||||||
}
|
|
||||||
const item = (
|
const item = (
|
||||||
<SessionRow
|
<SessionRow
|
||||||
session={props.session}
|
session={props.session}
|
||||||
@@ -301,86 +203,74 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
hasPermissions={hasPermissions}
|
hasPermissions={hasPermissions}
|
||||||
hasError={hasError}
|
hasError={hasError}
|
||||||
unseenCount={unseenCount}
|
unseenCount={unseenCount}
|
||||||
setHoverSession={props.setHoverSession}
|
|
||||||
clearHoverProjectSoon={props.clearHoverProjectSoon}
|
clearHoverProjectSoon={props.clearHoverProjectSoon}
|
||||||
sidebarOpened={layout.sidebar.opened}
|
sidebarOpened={layout.sidebar.opened}
|
||||||
warmHover={scheduleHoverPrefetch}
|
|
||||||
warmPress={() => warm(2, "high")}
|
warmPress={() => warm(2, "high")}
|
||||||
warmFocus={() => warm(2, "high")}
|
warmFocus={() => warm(2, "high")}
|
||||||
cancelHoverPrefetch={cancelHoverPrefetch}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
data-session-id={props.session.id}
|
<div
|
||||||
class="group/session relative w-full min-w-0 rounded-md cursor-default pl-2 pr-3 transition-colors
|
data-session-id={props.session.id}
|
||||||
hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
|
class="group/session relative w-full min-w-0 rounded-md cursor-default pr-3 transition-colors hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
|
||||||
>
|
style={{ "padding-left": `${8 + (props.level ?? 0) * 16}px` }}
|
||||||
<div class="flex min-w-0 items-center gap-1">
|
>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="flex min-w-0 items-center gap-1">
|
||||||
<Show
|
<div class="min-w-0 flex-1">
|
||||||
when={hoverEnabled()}
|
<Show
|
||||||
fallback={
|
when={!tooltip()}
|
||||||
<Tooltip
|
fallback={
|
||||||
placement={props.mobile ? "bottom" : "right"}
|
<Tooltip
|
||||||
value={sessionTitle(props.session.title)}
|
placement={props.mobile ? "bottom" : "right"}
|
||||||
gutter={10}
|
value={sessionTitle(props.session.title)}
|
||||||
class="min-w-0 w-full"
|
gutter={10}
|
||||||
>
|
class="min-w-0 w-full"
|
||||||
{item}
|
>
|
||||||
</Tooltip>
|
{item}
|
||||||
}
|
</Tooltip>
|
||||||
>
|
}
|
||||||
<SessionHoverPreview
|
>
|
||||||
mobile={props.mobile}
|
{item}
|
||||||
nav={props.nav}
|
</Show>
|
||||||
hoverSession={props.hoverSession}
|
</div>
|
||||||
session={props.session}
|
|
||||||
sidebarHovering={props.sidebarHovering}
|
|
||||||
hoverReady={hoverReady}
|
|
||||||
hoverMessages={hoverMessages}
|
|
||||||
language={language}
|
|
||||||
isActive={isActive}
|
|
||||||
slug={props.slug}
|
|
||||||
setHoverSession={props.setHoverSession}
|
|
||||||
messageLabel={messageLabel}
|
|
||||||
onMessageSelect={(message) => {
|
|
||||||
if (!isActive())
|
|
||||||
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
|
|
||||||
|
|
||||||
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
|
<Show when={!props.level}>
|
||||||
|
<div
|
||||||
|
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
||||||
|
classList={{
|
||||||
|
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
|
||||||
|
"w-0 opacity-0 pointer-events-none": !props.mobile,
|
||||||
|
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
|
||||||
|
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
|
||||||
}}
|
}}
|
||||||
trigger={item}
|
>
|
||||||
/>
|
<Tooltip value={language.t("common.archive")} placement="top">
|
||||||
|
<IconButton
|
||||||
|
icon="archive"
|
||||||
|
variant="ghost"
|
||||||
|
class="size-6 rounded-md"
|
||||||
|
aria-label={language.t("common.archive")}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
void props.archiveSession(props.session)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
|
||||||
classList={{
|
|
||||||
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
|
|
||||||
"w-0 opacity-0 pointer-events-none": !props.mobile,
|
|
||||||
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
|
|
||||||
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tooltip value={language.t("common.archive")} placement="top">
|
|
||||||
<IconButton
|
|
||||||
icon="archive"
|
|
||||||
variant="ghost"
|
|
||||||
class="size-6 rounded-md"
|
|
||||||
aria-label={language.t("common.archive")}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
void props.archiveSession(props.session)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Show when={currentChild()}>
|
||||||
|
{(child) => (
|
||||||
|
<div class="w-full">
|
||||||
|
<SessionItem {...props} session={child()} level={(props.level ?? 0) + 1} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,7 +280,6 @@ export const NewSessionItem = (props: {
|
|||||||
dense?: boolean
|
dense?: boolean
|
||||||
sidebarExpanded: Accessor<boolean>
|
sidebarExpanded: Accessor<boolean>
|
||||||
clearHoverProjectSoon: () => void
|
clearHoverProjectSoon: () => void
|
||||||
setHoverSession: (id: string | undefined) => void
|
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -400,9 +289,8 @@ export const NewSessionItem = (props: {
|
|||||||
<A
|
<A
|
||||||
href={`/${props.slug}/session`}
|
href={`/${props.slug}/session`}
|
||||||
end
|
end
|
||||||
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.setHoverSession(undefined)
|
|
||||||
if (layout.sidebar.opened()) return
|
if (layout.sidebar.opened()) return
|
||||||
props.clearHoverProjectSoon()
|
props.clearHoverProjectSoon()
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
@@ -11,7 +11,7 @@ import { useGlobalSync } from "@/context/global-sync"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
|
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
|
||||||
import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
|
import { displayName, sortedRootSessions } from "./helpers"
|
||||||
|
|
||||||
export type ProjectSidebarContext = {
|
export type ProjectSidebarContext = {
|
||||||
currentDir: Accessor<string>
|
currentDir: Accessor<string>
|
||||||
@@ -19,7 +19,6 @@ export type ProjectSidebarContext = {
|
|||||||
sidebarOpened: Accessor<boolean>
|
sidebarOpened: Accessor<boolean>
|
||||||
sidebarHovering: Accessor<boolean>
|
sidebarHovering: Accessor<boolean>
|
||||||
hoverProject: Accessor<string | undefined>
|
hoverProject: Accessor<string | undefined>
|
||||||
nav: Accessor<HTMLElement | undefined>
|
|
||||||
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
||||||
onProjectMouseLeave: (worktree: string) => void
|
onProjectMouseLeave: (worktree: string) => void
|
||||||
onProjectFocus: (worktree: string) => void
|
onProjectFocus: (worktree: string) => void
|
||||||
@@ -32,8 +31,7 @@ export type ProjectSidebarContext = {
|
|||||||
workspacesEnabled: (project: LocalProject) => boolean
|
workspacesEnabled: (project: LocalProject) => boolean
|
||||||
workspaceIds: (project: LocalProject) => string[]
|
workspaceIds: (project: LocalProject) => string[]
|
||||||
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
|
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
|
||||||
sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "children" | "mobile" | "dense" | "popover">
|
sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "mobile" | "dense">
|
||||||
setHoverSession: (id: string | undefined) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProjectDragOverlay = (props: {
|
export const ProjectDragOverlay = (props: {
|
||||||
@@ -55,7 +53,6 @@ export const ProjectDragOverlay = (props: {
|
|||||||
const ProjectTile = (props: {
|
const ProjectTile = (props: {
|
||||||
project: LocalProject
|
project: LocalProject
|
||||||
mobile?: boolean
|
mobile?: boolean
|
||||||
nav: Accessor<HTMLElement | undefined>
|
|
||||||
sidebarHovering: Accessor<boolean>
|
sidebarHovering: Accessor<boolean>
|
||||||
selected: Accessor<boolean>
|
selected: Accessor<boolean>
|
||||||
active: Accessor<boolean>
|
active: Accessor<boolean>
|
||||||
@@ -195,9 +192,7 @@ const ProjectPreviewPanel = (props: {
|
|||||||
workspaces: Accessor<string[]>
|
workspaces: Accessor<string[]>
|
||||||
label: (directory: string) => string
|
label: (directory: string) => string
|
||||||
projectSessions: Accessor<ReturnType<typeof sortedRootSessions>>
|
projectSessions: Accessor<ReturnType<typeof sortedRootSessions>>
|
||||||
projectChildren: Accessor<Map<string, string[]>>
|
|
||||||
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
|
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
|
||||||
workspaceChildren: (directory: string) => Map<string, string[]>
|
|
||||||
ctx: ProjectSidebarContext
|
ctx: ProjectSidebarContext
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
}): JSX.Element => (
|
}): JSX.Element => (
|
||||||
@@ -218,9 +213,8 @@ const ProjectPreviewPanel = (props: {
|
|||||||
list={props.projectSessions()}
|
list={props.projectSessions()}
|
||||||
slug={base64Encode(props.project.worktree)}
|
slug={base64Encode(props.project.worktree)}
|
||||||
dense
|
dense
|
||||||
|
showTooltip
|
||||||
mobile={props.mobile}
|
mobile={props.mobile}
|
||||||
popover={false}
|
|
||||||
children={props.projectChildren()}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -229,7 +223,6 @@ const ProjectPreviewPanel = (props: {
|
|||||||
<For each={props.workspaces()}>
|
<For each={props.workspaces()}>
|
||||||
{(directory) => {
|
{(directory) => {
|
||||||
const sessions = createMemo(() => props.workspaceSessions(directory))
|
const sessions = createMemo(() => props.workspaceSessions(directory))
|
||||||
const children = createMemo(() => props.workspaceChildren(directory))
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
|
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
|
||||||
@@ -246,9 +239,8 @@ const ProjectPreviewPanel = (props: {
|
|||||||
list={sessions()}
|
list={sessions()}
|
||||||
slug={base64Encode(directory)}
|
slug={base64Encode(directory)}
|
||||||
dense
|
dense
|
||||||
|
showTooltip
|
||||||
mobile={props.mobile}
|
mobile={props.mobile}
|
||||||
popover={false}
|
|
||||||
children={children()}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -310,20 +302,14 @@ export const SortableProject = (props: {
|
|||||||
|
|
||||||
const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
|
const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
|
||||||
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
|
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
|
||||||
const projectChildren = createMemo(() => childMapByParent(projectStore().session))
|
|
||||||
const workspaceSessions = (directory: string) => {
|
const workspaceSessions = (directory: string) => {
|
||||||
const [data] = globalSync.child(directory, { bootstrap: false })
|
const [data] = globalSync.child(directory, { bootstrap: false })
|
||||||
return sortedRootSessions(data, props.sortNow())
|
return sortedRootSessions(data, props.sortNow())
|
||||||
}
|
}
|
||||||
const workspaceChildren = (directory: string) => {
|
|
||||||
const [data] = globalSync.child(directory, { bootstrap: false })
|
|
||||||
return childMapByParent(data.session)
|
|
||||||
}
|
|
||||||
const tile = () => (
|
const tile = () => (
|
||||||
<ProjectTile
|
<ProjectTile
|
||||||
project={props.project}
|
project={props.project}
|
||||||
mobile={props.mobile}
|
mobile={props.mobile}
|
||||||
nav={props.ctx.nav}
|
|
||||||
sidebarHovering={props.ctx.sidebarHovering}
|
sidebarHovering={props.ctx.sidebarHovering}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
active={active}
|
active={active}
|
||||||
@@ -360,7 +346,6 @@ export const SortableProject = (props: {
|
|||||||
if (state.menu) return
|
if (state.menu) return
|
||||||
if (value && state.suppressHover) return
|
if (value && state.suppressHover) return
|
||||||
props.ctx.onHoverOpenChanged(props.project.worktree, value)
|
props.ctx.onHoverOpenChanged(props.project.worktree, value)
|
||||||
if (value) props.ctx.setHoverSession(undefined)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ProjectPreviewPanel
|
<ProjectPreviewPanel
|
||||||
@@ -371,9 +356,7 @@ export const SortableProject = (props: {
|
|||||||
workspaces={workspaces}
|
workspaces={workspaces}
|
||||||
label={label}
|
label={label}
|
||||||
projectSessions={projectSessions}
|
projectSessions={projectSessions}
|
||||||
projectChildren={projectChildren}
|
|
||||||
workspaceSessions={workspaceSessions}
|
workspaceSessions={workspaceSessions}
|
||||||
workspaceChildren={workspaceChildren}
|
|
||||||
ctx={props.ctx}
|
ctx={props.ctx}
|
||||||
language={language}
|
language={language}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout"
|
|||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
||||||
import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers"
|
import { sortedRootSessions, workspaceKey } from "./helpers"
|
||||||
|
|
||||||
type InlineEditorComponent = (props: {
|
type InlineEditorComponent = (props: {
|
||||||
id: string
|
id: string
|
||||||
@@ -35,9 +35,6 @@ export type WorkspaceSidebarContext = {
|
|||||||
navList: Accessor<Session[]>
|
navList: Accessor<Session[]>
|
||||||
sidebarExpanded: Accessor<boolean>
|
sidebarExpanded: Accessor<boolean>
|
||||||
sidebarHovering: Accessor<boolean>
|
sidebarHovering: Accessor<boolean>
|
||||||
nav: Accessor<HTMLElement | undefined>
|
|
||||||
hoverSession: Accessor<string | undefined>
|
|
||||||
setHoverSession: (id: string | undefined) => void
|
|
||||||
clearHoverProjectSoon: () => void
|
clearHoverProjectSoon: () => void
|
||||||
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
||||||
archiveSession: (session: Session) => Promise<void>
|
archiveSession: (session: Session) => Promise<void>
|
||||||
@@ -152,7 +149,6 @@ const WorkspaceActions = (props: {
|
|||||||
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
|
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
|
||||||
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
|
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
|
||||||
root: string
|
root: string
|
||||||
setHoverSession: WorkspaceSidebarContext["setHoverSession"]
|
|
||||||
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
|
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
|
||||||
navigateToNewSession: () => void
|
navigateToNewSession: () => void
|
||||||
}): JSX.Element => (
|
}): JSX.Element => (
|
||||||
@@ -226,7 +222,6 @@ const WorkspaceActions = (props: {
|
|||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
props.setHoverSession(undefined)
|
|
||||||
props.clearHoverProjectSoon()
|
props.clearHoverProjectSoon()
|
||||||
props.navigateToNewSession()
|
props.navigateToNewSession()
|
||||||
}}
|
}}
|
||||||
@@ -239,12 +234,10 @@ const WorkspaceActions = (props: {
|
|||||||
const WorkspaceSessionList = (props: {
|
const WorkspaceSessionList = (props: {
|
||||||
slug: Accessor<string>
|
slug: Accessor<string>
|
||||||
mobile?: boolean
|
mobile?: boolean
|
||||||
popover?: boolean
|
|
||||||
ctx: WorkspaceSidebarContext
|
ctx: WorkspaceSidebarContext
|
||||||
showNew: Accessor<boolean>
|
showNew: Accessor<boolean>
|
||||||
loading: Accessor<boolean>
|
loading: Accessor<boolean>
|
||||||
sessions: Accessor<Session[]>
|
sessions: Accessor<Session[]>
|
||||||
children: Accessor<Map<string, string[]>>
|
|
||||||
hasMore: Accessor<boolean>
|
hasMore: Accessor<boolean>
|
||||||
loadMore: () => Promise<void>
|
loadMore: () => Promise<void>
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
@@ -256,7 +249,6 @@ const WorkspaceSessionList = (props: {
|
|||||||
mobile={props.mobile}
|
mobile={props.mobile}
|
||||||
sidebarExpanded={props.ctx.sidebarExpanded}
|
sidebarExpanded={props.ctx.sidebarExpanded}
|
||||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||||
setHoverSession={props.ctx.setHoverSession}
|
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.loading()}>
|
<Show when={props.loading()}>
|
||||||
@@ -270,13 +262,8 @@ const WorkspaceSessionList = (props: {
|
|||||||
navList={props.ctx.navList}
|
navList={props.ctx.navList}
|
||||||
slug={props.slug()}
|
slug={props.slug()}
|
||||||
mobile={props.mobile}
|
mobile={props.mobile}
|
||||||
popover={props.popover}
|
showChild
|
||||||
children={props.children()}
|
|
||||||
sidebarExpanded={props.ctx.sidebarExpanded}
|
sidebarExpanded={props.ctx.sidebarExpanded}
|
||||||
sidebarHovering={props.ctx.sidebarHovering}
|
|
||||||
nav={props.ctx.nav}
|
|
||||||
hoverSession={props.ctx.hoverSession}
|
|
||||||
setHoverSession={props.ctx.setHoverSession}
|
|
||||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||||
prefetchSession={props.ctx.prefetchSession}
|
prefetchSession={props.ctx.prefetchSession}
|
||||||
archiveSession={props.ctx.archiveSession}
|
archiveSession={props.ctx.archiveSession}
|
||||||
@@ -307,7 +294,6 @@ export const SortableWorkspace = (props: {
|
|||||||
project: LocalProject
|
project: LocalProject
|
||||||
sortNow: Accessor<number>
|
sortNow: Accessor<number>
|
||||||
mobile?: boolean
|
mobile?: boolean
|
||||||
popover?: boolean
|
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -321,7 +307,6 @@ export const SortableWorkspace = (props: {
|
|||||||
})
|
})
|
||||||
const slug = createMemo(() => base64Encode(props.directory))
|
const slug = createMemo(() => base64Encode(props.directory))
|
||||||
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
|
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
|
||||||
const children = createMemo(() => childMapByParent(workspaceStore.session))
|
|
||||||
const local = createMemo(() => props.directory === props.project.worktree)
|
const local = createMemo(() => props.directory === props.project.worktree)
|
||||||
const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
|
const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
|
||||||
const workspaceValue = createMemo(() => {
|
const workspaceValue = createMemo(() => {
|
||||||
@@ -428,7 +413,6 @@ export const SortableWorkspace = (props: {
|
|||||||
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
|
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
|
||||||
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
|
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
|
||||||
root={props.project.worktree}
|
root={props.project.worktree}
|
||||||
setHoverSession={props.ctx.setHoverSession}
|
|
||||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||||
navigateToNewSession={() => navigate(`/${slug()}/session`)}
|
navigateToNewSession={() => navigate(`/${slug()}/session`)}
|
||||||
/>
|
/>
|
||||||
@@ -440,12 +424,10 @@ export const SortableWorkspace = (props: {
|
|||||||
<WorkspaceSessionList
|
<WorkspaceSessionList
|
||||||
slug={slug}
|
slug={slug}
|
||||||
mobile={props.mobile}
|
mobile={props.mobile}
|
||||||
popover={props.popover}
|
|
||||||
ctx={props.ctx}
|
ctx={props.ctx}
|
||||||
showNew={showNew}
|
showNew={showNew}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
children={children}
|
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
loadMore={loadMore}
|
loadMore={loadMore}
|
||||||
language={language}
|
language={language}
|
||||||
@@ -461,7 +443,6 @@ export const LocalWorkspace = (props: {
|
|||||||
project: LocalProject
|
project: LocalProject
|
||||||
sortNow: Accessor<number>
|
sortNow: Accessor<number>
|
||||||
mobile?: boolean
|
mobile?: boolean
|
||||||
popover?: boolean
|
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -471,7 +452,6 @@ export const LocalWorkspace = (props: {
|
|||||||
})
|
})
|
||||||
const slug = createMemo(() => base64Encode(props.project.worktree))
|
const slug = createMemo(() => base64Encode(props.project.worktree))
|
||||||
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
|
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
|
||||||
const children = createMemo(() => childMapByParent(workspace().store.session))
|
|
||||||
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
|
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
|
||||||
const count = createMemo(() => sessions()?.length ?? 0)
|
const count = createMemo(() => sessions()?.length ?? 0)
|
||||||
const loading = createMemo(() => !booted() && count() === 0)
|
const loading = createMemo(() => !booted() && count() === 0)
|
||||||
@@ -489,12 +469,10 @@ export const LocalWorkspace = (props: {
|
|||||||
<WorkspaceSessionList
|
<WorkspaceSessionList
|
||||||
slug={slug}
|
slug={slug}
|
||||||
mobile={props.mobile}
|
mobile={props.mobile}
|
||||||
popover={props.popover}
|
|
||||||
ctx={props.ctx}
|
ctx={props.ctx}
|
||||||
showNew={() => false}
|
showNew={() => false}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
children={children}
|
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
loadMore={loadMore}
|
loadMore={loadMore}
|
||||||
language={language}
|
language={language}
|
||||||
|
|||||||
@@ -429,6 +429,7 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
|
const isChildSession = createMemo(() => !!info()?.parentID)
|
||||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||||
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||||
const hasSessionReview = createMemo(() => sessionCount() > 0)
|
const hasSessionReview = createMemo(() => sessionCount() > 0)
|
||||||
@@ -1058,7 +1059,7 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
||||||
if (composer.blocked()) return
|
if (composer.blocked() || isChildSession()) return
|
||||||
inputRef?.focus()
|
inputRef?.focus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1127,7 +1128,10 @@ export default function Page() {
|
|||||||
setFileTreeTab("all")
|
setFileTreeTab("all")
|
||||||
}
|
}
|
||||||
|
|
||||||
const focusInput = () => inputRef?.focus()
|
const focusInput = () => {
|
||||||
|
if (isChildSession()) return
|
||||||
|
inputRef?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
useSessionCommands({
|
useSessionCommands({
|
||||||
navigateMessageByOffset,
|
navigateMessageByOffset,
|
||||||
@@ -1658,7 +1662,7 @@ export default function Page() {
|
|||||||
const queueEnabled = createMemo(() => {
|
const queueEnabled = createMemo(() => {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return false
|
if (!id) return false
|
||||||
return settings.general.followup() === "queue" && busy(id) && !composer.blocked()
|
return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession()
|
||||||
})
|
})
|
||||||
|
|
||||||
const followupText = (item: FollowupDraft) => {
|
const followupText = (item: FollowupDraft) => {
|
||||||
@@ -1690,6 +1694,7 @@ export default function Page() {
|
|||||||
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
|
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
|
||||||
|
|
||||||
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
||||||
|
if (sync.session.get(sessionID)?.parentID) return Promise.resolve()
|
||||||
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
||||||
if (!item) return Promise.resolve()
|
if (!item) return Promise.resolve()
|
||||||
if (followupBusy(sessionID)) return Promise.resolve()
|
if (followupBusy(sessionID)) return Promise.resolve()
|
||||||
@@ -1820,6 +1825,7 @@ export default function Page() {
|
|||||||
if (followupBusy(sessionID)) return
|
if (followupBusy(sessionID)) return
|
||||||
if (followup.failed[sessionID] === item.id) return
|
if (followup.failed[sessionID] === item.id) return
|
||||||
if (followup.paused[sessionID]) return
|
if (followup.paused[sessionID]) return
|
||||||
|
if (isChildSession()) return
|
||||||
if (composer.blocked()) return
|
if (composer.blocked()) return
|
||||||
if (busy(sessionID)) return
|
if (busy(sessionID)) return
|
||||||
|
|
||||||
@@ -2001,7 +2007,7 @@ export default function Page() {
|
|||||||
}}
|
}}
|
||||||
onResponseSubmit={resumeScroll}
|
onResponseSubmit={resumeScroll}
|
||||||
followup={
|
followup={
|
||||||
params.id
|
params.id && !isChildSession()
|
||||||
? {
|
? {
|
||||||
queue: queueEnabled,
|
queue: queueEnabled,
|
||||||
items: followupDock(),
|
items: followupDock(),
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Show, createEffect, createMemo, onCleanup } from "solid-js"
|
import { Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||||
import { PromptInput } from "@/components/prompt-input"
|
import { PromptInput } from "@/components/prompt-input"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||||
import { useSessionKey } from "@/pages/session/session-layout"
|
import { useSessionKey } from "@/pages/session/session-layout"
|
||||||
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
||||||
@@ -43,11 +45,17 @@ export function SessionComposerRegion(props: {
|
|||||||
}
|
}
|
||||||
setPromptDockRef: (el: HTMLDivElement) => void
|
setPromptDockRef: (el: HTMLDivElement) => void
|
||||||
}) {
|
}) {
|
||||||
|
const navigate = useNavigate()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const route = useSessionKey()
|
const route = useSessionKey()
|
||||||
|
const sync = useSync()
|
||||||
|
|
||||||
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
|
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
|
||||||
|
const info = createMemo(() => (route.params.id ? sync.session.get(route.params.id) : undefined))
|
||||||
|
const parentID = createMemo(() => info()?.parentID)
|
||||||
|
const child = createMemo(() => !!parentID())
|
||||||
|
const showComposer = createMemo(() => !props.state.blocked() || child())
|
||||||
|
|
||||||
const previewPrompt = () =>
|
const previewPrompt = () =>
|
||||||
prompt
|
prompt
|
||||||
@@ -113,6 +121,12 @@ export function SessionComposerRegion(props: {
|
|||||||
const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
|
const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
|
||||||
const full = createMemo(() => Math.max(78, store.height))
|
const full = createMemo(() => Math.max(78, store.height))
|
||||||
|
|
||||||
|
const openParent = () => {
|
||||||
|
const id = parentID()
|
||||||
|
if (!id) return
|
||||||
|
navigate(`/${route.params.dir}/session/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const el = store.body
|
const el = store.body
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@@ -156,7 +170,7 @@ export function SessionComposerRegion(props: {
|
|||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={!props.state.blocked()}>
|
<Show when={showComposer()}>
|
||||||
<Show
|
<Show
|
||||||
when={prompt.ready()}
|
when={prompt.ready()}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -232,17 +246,40 @@ export function SessionComposerRegion(props: {
|
|||||||
onEdit={props.followup!.onEdit}
|
onEdit={props.followup!.onEdit}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<PromptInput
|
<Show
|
||||||
ref={props.inputRef}
|
when={child()}
|
||||||
newSessionWorktree={props.newSessionWorktree}
|
fallback={
|
||||||
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
|
<Show when={!props.state.blocked()}>
|
||||||
edit={props.followup?.edit}
|
<PromptInput
|
||||||
onEditLoaded={props.followup?.onEditLoaded}
|
ref={props.inputRef}
|
||||||
shouldQueue={props.followup?.queue}
|
newSessionWorktree={props.newSessionWorktree}
|
||||||
onQueue={props.followup?.onQueue}
|
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
|
||||||
onAbort={props.followup?.onAbort}
|
edit={props.followup?.edit}
|
||||||
onSubmit={props.onSubmit}
|
onEditLoaded={props.followup?.onEditLoaded}
|
||||||
/>
|
shouldQueue={props.followup?.queue}
|
||||||
|
onQueue={props.followup?.onQueue}
|
||||||
|
onAbort={props.followup?.onAbort}
|
||||||
|
onSubmit={props.onSubmit}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={props.inputRef}
|
||||||
|
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
|
||||||
|
>
|
||||||
|
<span>{language.t("session.child.promptDisabled")} </span>
|
||||||
|
<Show when={parentID()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-base transition-colors hover:text-text-strong"
|
||||||
|
onClick={openParent}
|
||||||
|
>
|
||||||
|
{language.t("session.child.backToParent")}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
|||||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSessionKey } from "@/pages/session/session-layout"
|
import { useSessionKey } from "@/pages/session/session-layout"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
@@ -68,6 +69,16 @@ const messageComments = (parts: Part[]): MessageComment[] =>
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const taskDescription = (part: Part, sessionID: string) => {
|
||||||
|
if (part.type !== "tool" || part.tool !== "task") return
|
||||||
|
const metadata = "metadata" in part.state ? part.state.metadata : undefined
|
||||||
|
if (metadata?.sessionId !== sessionID) return
|
||||||
|
const value = part.state.input?.description
|
||||||
|
if (typeof value === "string" && value) return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const pace = (width: number) => Math.round(Math.max(1200, Math.min(3200, (Math.max(width, 360) * 2000) / 900)))
|
||||||
|
|
||||||
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
|
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
|
||||||
const current = target instanceof Element ? target : undefined
|
const current = target instanceof Element ? target : undefined
|
||||||
const nested = current?.closest("[data-scrollable]")
|
const nested = current?.closest("[data-scrollable]")
|
||||||
@@ -295,6 +306,32 @@ export function MessageTimeline(props: {
|
|||||||
const shareUrl = createMemo(() => info()?.share?.url)
|
const shareUrl = createMemo(() => info()?.share?.url)
|
||||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||||
const parentID = createMemo(() => info()?.parentID)
|
const parentID = createMemo(() => info()?.parentID)
|
||||||
|
const parent = createMemo(() => {
|
||||||
|
const id = parentID()
|
||||||
|
if (!id) return
|
||||||
|
return sync.session.get(id)
|
||||||
|
})
|
||||||
|
const parentMessages = createMemo(() => {
|
||||||
|
const id = parentID()
|
||||||
|
if (!id) return emptyMessages
|
||||||
|
return sync.data.message[id] ?? emptyMessages
|
||||||
|
})
|
||||||
|
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
|
||||||
|
const childTaskDescription = createMemo(() => {
|
||||||
|
const id = sessionID()
|
||||||
|
if (!id) return
|
||||||
|
return parentMessages()
|
||||||
|
.flatMap((message) => sync.data.part[message.id] ?? [])
|
||||||
|
.map((part) => taskDescription(part, id))
|
||||||
|
.findLast((value): value is string => !!value)
|
||||||
|
})
|
||||||
|
const childTitle = createMemo(() => {
|
||||||
|
if (!parentID()) return titleLabel() ?? ""
|
||||||
|
if (childTaskDescription()) return childTaskDescription()
|
||||||
|
const value = titleLabel()?.replace(/\s+\(@[^)]+ subagent\)$/, "")
|
||||||
|
if (value) return value
|
||||||
|
return language.t("command.session.new")
|
||||||
|
})
|
||||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||||
const stageCfg = { init: 1, batch: 3 }
|
const stageCfg = { init: 1, batch: 3 }
|
||||||
const staging = createTimelineStaging({
|
const staging = createTimelineStaging({
|
||||||
@@ -317,8 +354,20 @@ export function MessageTimeline(props: {
|
|||||||
open: false,
|
open: false,
|
||||||
dismiss: null as "escape" | "outside" | null,
|
dismiss: null as "escape" | "outside" | null,
|
||||||
})
|
})
|
||||||
|
const [bar, setBar] = createStore({
|
||||||
|
ms: pace(640),
|
||||||
|
})
|
||||||
|
|
||||||
let more: HTMLButtonElement | undefined
|
let more: HTMLButtonElement | undefined
|
||||||
|
let head: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
createResizeObserver(
|
||||||
|
() => head,
|
||||||
|
() => {
|
||||||
|
if (!head || head.clientWidth <= 0) return
|
||||||
|
setBar("ms", pace(head.clientWidth))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const viewShare = () => {
|
const viewShare = () => {
|
||||||
const url = shareUrl()
|
const url = shareUrl()
|
||||||
@@ -398,8 +447,20 @@ export function MessageTimeline(props: {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => [parentID(), childTaskDescription()] as const,
|
||||||
|
([id, description]) => {
|
||||||
|
if (!id || description) return
|
||||||
|
if (sync.data.message[id] !== undefined) return
|
||||||
|
void sync.session.sync(id)
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const openTitleEditor = () => {
|
const openTitleEditor = () => {
|
||||||
if (!sessionID()) return
|
if (!sessionID() || parentID()) return
|
||||||
setTitle({ editing: true, draft: titleLabel() ?? "" })
|
setTitle({ editing: true, draft: titleLabel() ?? "" })
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
titleRef?.focus()
|
titleRef?.focus()
|
||||||
@@ -638,27 +699,53 @@ export function MessageTimeline(props: {
|
|||||||
<div ref={props.setContentRef} class="min-w-0 w-full">
|
<div ref={props.setContentRef} class="min-w-0 w-full">
|
||||||
<Show when={showHeader()}>
|
<Show when={showHeader()}>
|
||||||
<div
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
head = el
|
||||||
|
setBar("ms", pace(el.clientWidth))
|
||||||
|
}}
|
||||||
data-session-title
|
data-session-title
|
||||||
classList={{
|
classList={{
|
||||||
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
||||||
|
relative: true,
|
||||||
"w-full": true,
|
"w-full": true,
|
||||||
"pb-4": true,
|
"pb-4": true,
|
||||||
"pl-2 pr-3 md:pl-4 md:pr-3": true,
|
"pl-2 pr-3 md:pl-4 md:pr-3": true,
|
||||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Show when={workingStatus() !== "hidden"}>
|
||||||
|
<div
|
||||||
|
data-component="session-progress"
|
||||||
|
data-state={workingStatus()}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
"--session-progress-color": tint() ?? "var(--icon-interactive-base)",
|
||||||
|
"--session-progress-ms": `${bar.ms}ms`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div data-component="session-progress-bar" />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
||||||
<Show when={parentID()}>
|
|
||||||
<IconButton
|
|
||||||
tabIndex={-1}
|
|
||||||
icon="arrow-left"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={navigateParent}
|
|
||||||
aria-label={language.t("common.goBack")}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<div class="flex items-center min-w-0 grow-1">
|
<div class="flex items-center min-w-0 grow-1">
|
||||||
|
<Show when={parentID()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-slot="session-title-parent"
|
||||||
|
class="min-w-0 max-w-[40%] truncate text-14-medium text-text-weak transition-colors hover:text-text-base"
|
||||||
|
onClick={navigateParent}
|
||||||
|
>
|
||||||
|
{parentTitle()}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
data-slot="session-title-separator"
|
||||||
|
class="px-2 text-14-medium text-text-weak"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
<div
|
<div
|
||||||
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||||
style={{
|
style={{
|
||||||
@@ -676,15 +763,16 @@ export function MessageTimeline(props: {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={titleLabel() || title.editing}>
|
<Show when={childTitle() || title.editing}>
|
||||||
<Show
|
<Show
|
||||||
when={title.editing}
|
when={title.editing}
|
||||||
fallback={
|
fallback={
|
||||||
<h1
|
<h1
|
||||||
|
data-slot="session-title-child"
|
||||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
|
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
|
||||||
onDblClick={openTitleEditor}
|
onDblClick={openTitleEditor}
|
||||||
>
|
>
|
||||||
{titleLabel()}
|
{childTitle()}
|
||||||
</h1>
|
</h1>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -692,6 +780,7 @@ export function MessageTimeline(props: {
|
|||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
titleRef = el
|
titleRef = el
|
||||||
}}
|
}}
|
||||||
|
data-slot="session-title-child"
|
||||||
value={title.draft}
|
value={title.draft}
|
||||||
disabled={titleMutation.isPending}
|
disabled={titleMutation.isPending}
|
||||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
||||||
@@ -719,177 +808,179 @@ export function MessageTimeline(props: {
|
|||||||
{(id) => (
|
{(id) => (
|
||||||
<div class="shrink-0 flex items-center gap-3">
|
<div class="shrink-0 flex items-center gap-3">
|
||||||
<SessionContextUsage placement="bottom" />
|
<SessionContextUsage placement="bottom" />
|
||||||
<DropdownMenu
|
<Show when={!parentID()}>
|
||||||
gutter={4}
|
<DropdownMenu
|
||||||
placement="bottom-end"
|
gutter={4}
|
||||||
open={title.menuOpen}
|
placement="bottom-end"
|
||||||
onOpenChange={(open) => {
|
open={title.menuOpen}
|
||||||
setTitle("menuOpen", open)
|
onOpenChange={(open) => {
|
||||||
if (open) return
|
setTitle("menuOpen", open)
|
||||||
}}
|
if (open) return
|
||||||
>
|
|
||||||
<DropdownMenu.Trigger
|
|
||||||
as={IconButton}
|
|
||||||
icon="dot-grid"
|
|
||||||
variant="ghost"
|
|
||||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
|
||||||
classList={{
|
|
||||||
"bg-surface-base-active": share.open || title.pendingShare,
|
|
||||||
}}
|
}}
|
||||||
aria-label={language.t("common.moreOptions")}
|
>
|
||||||
aria-expanded={title.menuOpen || share.open || title.pendingShare}
|
<DropdownMenu.Trigger
|
||||||
ref={(el: HTMLButtonElement) => {
|
as={IconButton}
|
||||||
more = el
|
icon="dot-grid"
|
||||||
}}
|
variant="ghost"
|
||||||
/>
|
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||||
<DropdownMenu.Portal>
|
classList={{
|
||||||
<DropdownMenu.Content
|
"bg-surface-base-active": share.open || title.pendingShare,
|
||||||
style={{ "min-width": "104px" }}
|
|
||||||
onCloseAutoFocus={(event) => {
|
|
||||||
if (title.pendingRename) {
|
|
||||||
event.preventDefault()
|
|
||||||
setTitle("pendingRename", false)
|
|
||||||
openTitleEditor()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (title.pendingShare) {
|
|
||||||
event.preventDefault()
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setShare({ open: true, dismiss: null })
|
|
||||||
setTitle("pendingShare", false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
aria-label={language.t("common.moreOptions")}
|
||||||
<DropdownMenu.Item
|
aria-expanded={title.menuOpen || share.open || title.pendingShare}
|
||||||
onSelect={() => {
|
ref={(el: HTMLButtonElement) => {
|
||||||
setTitle("pendingRename", true)
|
more = el
|
||||||
setTitle("menuOpen", false)
|
}}
|
||||||
|
/>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
style={{ "min-width": "104px" }}
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (title.pendingRename) {
|
||||||
|
event.preventDefault()
|
||||||
|
setTitle("pendingRename", false)
|
||||||
|
openTitleEditor()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (title.pendingShare) {
|
||||||
|
event.preventDefault()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setShare({ open: true, dismiss: null })
|
||||||
|
setTitle("pendingShare", false)
|
||||||
|
})
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<Show when={shareEnabled()}>
|
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setTitle({ pendingShare: true, menuOpen: false })
|
setTitle("pendingRename", true)
|
||||||
|
setTitle("menuOpen", false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||||
{language.t("session.share.action.share")}
|
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
<Show when={shareEnabled()}>
|
||||||
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
|
<DropdownMenu.Item
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
onSelect={() => {
|
||||||
</DropdownMenu.Item>
|
setTitle({ pendingShare: true, menuOpen: false })
|
||||||
<DropdownMenu.Separator />
|
}}
|
||||||
<DropdownMenu.Item
|
|
||||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<KobaltePopover
|
|
||||||
open={share.open}
|
|
||||||
anchorRef={() => more}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={4}
|
|
||||||
modal={false}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (open) setShare("dismiss", null)
|
|
||||||
setShare("open", open)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<KobaltePopover.Portal>
|
|
||||||
<KobaltePopover.Content
|
|
||||||
data-component="popover-content"
|
|
||||||
style={{ "min-width": "320px" }}
|
|
||||||
onEscapeKeyDown={(event) => {
|
|
||||||
setShare({ dismiss: "escape", open: false })
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
onPointerDownOutside={() => {
|
|
||||||
setShare({ dismiss: "outside", open: false })
|
|
||||||
}}
|
|
||||||
onFocusOutside={() => {
|
|
||||||
setShare({ dismiss: "outside", open: false })
|
|
||||||
}}
|
|
||||||
onCloseAutoFocus={(event) => {
|
|
||||||
if (share.dismiss === "outside") event.preventDefault()
|
|
||||||
setShare("dismiss", null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="flex flex-col p-3">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<div class="text-13-medium text-text-strong">
|
|
||||||
{language.t("session.share.popover.title")}
|
|
||||||
</div>
|
|
||||||
<div class="text-12-regular text-text-weak">
|
|
||||||
{shareUrl()
|
|
||||||
? language.t("session.share.popover.description.shared")
|
|
||||||
: language.t("session.share.popover.description.unshared")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-3 flex flex-col gap-2">
|
|
||||||
<Show
|
|
||||||
when={shareUrl()}
|
|
||||||
fallback={
|
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
variant="primary"
|
|
||||||
class="w-full"
|
|
||||||
onClick={shareSession}
|
|
||||||
disabled={shareMutation.isPending}
|
|
||||||
>
|
|
||||||
{shareMutation.isPending
|
|
||||||
? language.t("session.share.action.publishing")
|
|
||||||
: language.t("session.share.action.publish")}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-2">
|
<DropdownMenu.ItemLabel>
|
||||||
<TextField
|
{language.t("session.share.action.share")}
|
||||||
value={shareUrl() ?? ""}
|
</DropdownMenu.ItemLabel>
|
||||||
readOnly
|
</DropdownMenu.Item>
|
||||||
copyable
|
</Show>
|
||||||
copyKind="link"
|
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
|
||||||
tabIndex={-1}
|
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||||
class="w-full"
|
</DropdownMenu.Item>
|
||||||
/>
|
<DropdownMenu.Separator />
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<DropdownMenu.Item
|
||||||
<Button
|
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
|
||||||
size="large"
|
>
|
||||||
variant="secondary"
|
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||||
class="w-full shadow-none border border-border-weak-base"
|
</DropdownMenu.Item>
|
||||||
onClick={unshareSession}
|
</DropdownMenu.Content>
|
||||||
disabled={unshareMutation.isPending}
|
</DropdownMenu.Portal>
|
||||||
>
|
</DropdownMenu>
|
||||||
{unshareMutation.isPending
|
|
||||||
? language.t("session.share.action.unpublishing")
|
<KobaltePopover
|
||||||
: language.t("session.share.action.unpublish")}
|
open={share.open}
|
||||||
</Button>
|
anchorRef={() => more}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={4}
|
||||||
|
modal={false}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) setShare("dismiss", null)
|
||||||
|
setShare("open", open)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KobaltePopover.Portal>
|
||||||
|
<KobaltePopover.Content
|
||||||
|
data-component="popover-content"
|
||||||
|
style={{ "min-width": "320px" }}
|
||||||
|
onEscapeKeyDown={(event) => {
|
||||||
|
setShare({ dismiss: "escape", open: false })
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onPointerDownOutside={() => {
|
||||||
|
setShare({ dismiss: "outside", open: false })
|
||||||
|
}}
|
||||||
|
onFocusOutside={() => {
|
||||||
|
setShare({ dismiss: "outside", open: false })
|
||||||
|
}}
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (share.dismiss === "outside") event.preventDefault()
|
||||||
|
setShare("dismiss", null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col p-3">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="text-13-medium text-text-strong">
|
||||||
|
{language.t("session.share.popover.title")}
|
||||||
|
</div>
|
||||||
|
<div class="text-12-regular text-text-weak">
|
||||||
|
{shareUrl()
|
||||||
|
? language.t("session.share.popover.description.shared")
|
||||||
|
: language.t("session.share.popover.description.unshared")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex flex-col gap-2">
|
||||||
|
<Show
|
||||||
|
when={shareUrl()}
|
||||||
|
fallback={
|
||||||
<Button
|
<Button
|
||||||
size="large"
|
size="large"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
onClick={viewShare}
|
onClick={shareSession}
|
||||||
disabled={unshareMutation.isPending}
|
disabled={shareMutation.isPending}
|
||||||
>
|
>
|
||||||
{language.t("session.share.action.view")}
|
{shareMutation.isPending
|
||||||
|
? language.t("session.share.action.publishing")
|
||||||
|
: language.t("session.share.action.publish")}
|
||||||
</Button>
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<TextField
|
||||||
|
value={shareUrl() ?? ""}
|
||||||
|
readOnly
|
||||||
|
copyable
|
||||||
|
copyKind="link"
|
||||||
|
tabIndex={-1}
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
variant="secondary"
|
||||||
|
class="w-full shadow-none border border-border-weak-base"
|
||||||
|
onClick={unshareSession}
|
||||||
|
disabled={unshareMutation.isPending}
|
||||||
|
>
|
||||||
|
{unshareMutation.isPending
|
||||||
|
? language.t("session.share.action.unpublishing")
|
||||||
|
: language.t("session.share.action.unpublish")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
variant="primary"
|
||||||
|
class="w-full"
|
||||||
|
onClick={viewShare}
|
||||||
|
disabled={unshareMutation.isPending}
|
||||||
|
>
|
||||||
|
{language.t("session.share.action.view")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</KobaltePopover.Content>
|
||||||
</KobaltePopover.Content>
|
</KobaltePopover.Portal>
|
||||||
</KobaltePopover.Portal>
|
</KobaltePopover>
|
||||||
</KobaltePopover>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -5,9 +5,30 @@ const defaults: Record<string, string> = {
|
|||||||
plan: "var(--icon-agent-plan-base)",
|
plan: "var(--icon-agent-plan-base)",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const palette = [
|
||||||
|
"var(--icon-agent-ask-base)",
|
||||||
|
"var(--icon-agent-build-base)",
|
||||||
|
"var(--icon-agent-docs-base)",
|
||||||
|
"var(--icon-agent-plan-base)",
|
||||||
|
"var(--syntax-info)",
|
||||||
|
"var(--syntax-success)",
|
||||||
|
"var(--syntax-warning)",
|
||||||
|
"var(--syntax-property)",
|
||||||
|
"var(--syntax-constant)",
|
||||||
|
"var(--text-diff-add-base)",
|
||||||
|
"var(--text-diff-delete-base)",
|
||||||
|
"var(--icon-warning-base)",
|
||||||
|
]
|
||||||
|
|
||||||
|
function tone(name: string) {
|
||||||
|
let hash = 0
|
||||||
|
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
|
||||||
|
return palette[hash % palette.length]
|
||||||
|
}
|
||||||
|
|
||||||
export function agentColor(name: string, custom?: string) {
|
export function agentColor(name: string, custom?: string) {
|
||||||
if (custom) return custom
|
if (custom) return custom
|
||||||
return defaults[name] ?? defaults[name.toLowerCase()]
|
return defaults[name] ?? defaults[name.toLowerCase()] ?? tone(name.toLowerCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
export function messageAgentColor(
|
export function messageAgentColor(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -90,7 +90,8 @@ export async function handler(
|
|||||||
const body = await input.request.json()
|
const body = await input.request.json()
|
||||||
const model = opts.parseModel(url, body)
|
const model = opts.parseModel(url, body)
|
||||||
const isStream = opts.parseIsStream(url, body)
|
const isStream = opts.parseIsStream(url, body)
|
||||||
const ip = input.request.headers.get("x-real-ip") ?? ""
|
const rawIp = input.request.headers.get("x-real-ip") ?? ""
|
||||||
|
const ip = rawIp.includes(":") ? rawIp.split(":").slice(0, 4).join(":") : rawIp
|
||||||
const sessionId = input.request.headers.get("x-opencode-session") ?? ""
|
const sessionId = input.request.headers.get("x-opencode-session") ?? ""
|
||||||
const requestId = input.request.headers.get("x-opencode-request") ?? ""
|
const requestId = input.request.headers.get("x-opencode-request") ?? ""
|
||||||
const projectId = input.request.headers.get("x-opencode-project") ?? ""
|
const projectId = input.request.headers.get("x-opencode-project") ?? ""
|
||||||
|
|||||||
@@ -17,9 +17,8 @@ export function createRateLimiter(
|
|||||||
const dict = i18n(localeFromRequest(request))
|
const dict = i18n(localeFromRequest(request))
|
||||||
|
|
||||||
const limits = Subscription.getFreeLimits()
|
const limits = Subscription.getFreeLimits()
|
||||||
const headerExists = request.headers.has(limits.checkHeader)
|
const dailyLimit = rateLimit ?? limits.dailyRequests
|
||||||
const dailyLimit = !headerExists ? limits.fallbackValue : (rateLimit ?? limits.dailyRequests)
|
const isDefaultModel = !rateLimit
|
||||||
const isDefaultModel = headerExists && !rateLimit
|
|
||||||
|
|
||||||
const ip = !rawIp.length ? "unknown" : rawIp
|
const ip = !rawIp.length ? "unknown" : rawIp
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ export namespace Subscription {
|
|||||||
free: z.object({
|
free: z.object({
|
||||||
promoTokens: z.number().int(),
|
promoTokens: z.number().int(),
|
||||||
dailyRequests: z.number().int(),
|
dailyRequests: z.number().int(),
|
||||||
checkHeader: z.string(),
|
|
||||||
fallbackValue: z.number().int(),
|
|
||||||
}),
|
}),
|
||||||
lite: z.object({
|
lite: z.object({
|
||||||
rollingLimit: z.number().int(),
|
rollingLimit: z.number().int(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop-electron",
|
"name": "@opencode-ai/desktop-electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://opencode.ai",
|
"homepage": "https://opencode.ai",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.3.15"
|
version = "1.3.17"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/anomalyco/opencode"
|
repository = "https://github.com/anomalyco/opencode"
|
||||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
|||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[agent_servers.opencode.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-darwin-arm64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-darwin-arm64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[agent_servers.opencode.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-darwin-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-darwin-x64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[agent_servers.opencode.targets.linux-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-linux-arm64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-linux-arm64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[agent_servers.opencode.targets.linux-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-linux-x64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-linux-x64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[agent_servers.opencode.targets.windows-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-windows-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-windows-x64.zip"
|
||||||
cmd = "./opencode.exe"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import {
|
|||||||
type Role,
|
type Role,
|
||||||
type SessionInfo,
|
type SessionInfo,
|
||||||
type SetSessionModelRequest,
|
type SetSessionModelRequest,
|
||||||
|
type SessionConfigOption,
|
||||||
|
type SetSessionConfigOptionRequest,
|
||||||
|
type SetSessionConfigOptionResponse,
|
||||||
type SetSessionModeRequest,
|
type SetSessionModeRequest,
|
||||||
type SetSessionModeResponse,
|
type SetSessionModeResponse,
|
||||||
type ToolCallContent,
|
type ToolCallContent,
|
||||||
@@ -601,6 +604,7 @@ export namespace ACP {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
|
configOptions: load.configOptions,
|
||||||
models: load.models,
|
models: load.models,
|
||||||
modes: load.modes,
|
modes: load.modes,
|
||||||
_meta: load._meta,
|
_meta: load._meta,
|
||||||
@@ -660,6 +664,11 @@ export namespace ACP {
|
|||||||
result.modes.currentModeId = lastUser.agent
|
result.modes.currentModeId = lastUser.agent
|
||||||
this.sessionManager.setMode(sessionId, lastUser.agent)
|
this.sessionManager.setMode(sessionId, lastUser.agent)
|
||||||
}
|
}
|
||||||
|
result.configOptions = buildConfigOptions({
|
||||||
|
currentModelId: result.models.currentModelId,
|
||||||
|
availableModels: result.models.availableModels,
|
||||||
|
modes: result.modes,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const msg of messages ?? []) {
|
for (const msg of messages ?? []) {
|
||||||
@@ -1266,6 +1275,11 @@ export namespace ACP {
|
|||||||
availableModels,
|
availableModels,
|
||||||
},
|
},
|
||||||
modes,
|
modes,
|
||||||
|
configOptions: buildConfigOptions({
|
||||||
|
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
|
||||||
|
availableModels,
|
||||||
|
modes,
|
||||||
|
}),
|
||||||
_meta: buildVariantMeta({
|
_meta: buildVariantMeta({
|
||||||
model,
|
model,
|
||||||
variant: this.sessionManager.getVariant(sessionId),
|
variant: this.sessionManager.getVariant(sessionId),
|
||||||
@@ -1305,6 +1319,44 @@ export namespace ACP {
|
|||||||
this.sessionManager.setMode(params.sessionId, params.modeId)
|
this.sessionManager.setMode(params.sessionId, params.modeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
|
||||||
|
const session = this.sessionManager.get(params.sessionId)
|
||||||
|
const providers = await this.sdk.config
|
||||||
|
.providers({ directory: session.cwd }, { throwOnError: true })
|
||||||
|
.then((x) => x.data!.providers)
|
||||||
|
const entries = sortProvidersByName(providers)
|
||||||
|
|
||||||
|
if (params.configId === "model") {
|
||||||
|
if (typeof params.value !== "string") throw RequestError.invalidParams("model value must be a string")
|
||||||
|
const selection = parseModelSelection(params.value, providers)
|
||||||
|
this.sessionManager.setModel(session.id, selection.model)
|
||||||
|
this.sessionManager.setVariant(session.id, selection.variant)
|
||||||
|
} else if (params.configId === "mode") {
|
||||||
|
if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string")
|
||||||
|
const availableModes = await this.loadAvailableModes(session.cwd)
|
||||||
|
if (!availableModes.some((mode) => mode.id === params.value)) {
|
||||||
|
throw RequestError.invalidParams(JSON.stringify({ error: `Mode not found: ${params.value}` }))
|
||||||
|
}
|
||||||
|
this.sessionManager.setMode(session.id, params.value)
|
||||||
|
} else {
|
||||||
|
throw RequestError.invalidParams(JSON.stringify({ error: `Unknown config option: ${params.configId}` }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSession = this.sessionManager.get(session.id)
|
||||||
|
const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd))
|
||||||
|
const availableVariants = modelVariantsFromProviders(entries, model)
|
||||||
|
const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, true)
|
||||||
|
const availableModels = buildAvailableModels(entries, { includeVariants: true })
|
||||||
|
const modeState = await this.resolveModeState(session.cwd, session.id)
|
||||||
|
const modes = modeState.currentModeId
|
||||||
|
? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId }
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
configOptions: buildConfigOptions({ currentModelId, availableModels, modes }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async prompt(params: PromptRequest) {
|
async prompt(params: PromptRequest) {
|
||||||
const sessionID = params.sessionId
|
const sessionID = params.sessionId
|
||||||
const session = this.sessionManager.get(sessionID)
|
const session = this.sessionManager.get(sessionID)
|
||||||
@@ -1760,4 +1812,36 @@ export namespace ACP {
|
|||||||
|
|
||||||
return { model: parsed, variant: undefined }
|
return { model: parsed, variant: undefined }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildConfigOptions(input: {
|
||||||
|
currentModelId: string
|
||||||
|
availableModels: ModelOption[]
|
||||||
|
modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined
|
||||||
|
}): SessionConfigOption[] {
|
||||||
|
const options: SessionConfigOption[] = [
|
||||||
|
{
|
||||||
|
id: "model",
|
||||||
|
name: "Model",
|
||||||
|
category: "model",
|
||||||
|
type: "select",
|
||||||
|
currentValue: input.currentModelId,
|
||||||
|
options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if (input.modes) {
|
||||||
|
options.push({
|
||||||
|
id: "mode",
|
||||||
|
name: "Session Mode",
|
||||||
|
category: "mode",
|
||||||
|
type: "select",
|
||||||
|
currentValue: input.modes.currentModeId,
|
||||||
|
options: input.modes.availableModes.map((m) => ({
|
||||||
|
value: m.id,
|
||||||
|
name: m.name,
|
||||||
|
...(m.description ? { description: m.description } : {}),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export namespace Auth {
|
|||||||
export class Api extends Schema.Class<Api>("ApiAuth")({
|
export class Api extends Schema.Class<Api>("ApiAuth")({
|
||||||
type: Schema.Literal("api"),
|
type: Schema.Literal("api"),
|
||||||
key: Schema.String,
|
key: Schema.String,
|
||||||
|
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
||||||
|
|||||||
@@ -128,10 +128,11 @@ function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
|
|||||||
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
|
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
externalOutputMode: "passthrough",
|
||||||
targetFps: 60,
|
targetFps: 60,
|
||||||
gatherStats: false,
|
gatherStats: false,
|
||||||
exitOnCtrlC: false,
|
exitOnCtrlC: false,
|
||||||
useKittyKeyboard: { events: process.platform === "win32" },
|
useKittyKeyboard: {},
|
||||||
autoFocus: false,
|
autoFocus: false,
|
||||||
openConsoleOnError: false,
|
openConsoleOnError: false,
|
||||||
useMouse: mouseEnabled,
|
useMouse: mouseEnabled,
|
||||||
@@ -760,6 +761,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
keybind: "terminal_suspend",
|
keybind: "terminal_suspend",
|
||||||
category: "System",
|
category: "System",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
|
enabled: tuiConfig.keybinds?.terminal_suspend !== "none",
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
process.once("SIGCONT", () => {
|
process.once("SIGCONT", () => {
|
||||||
renderer.resume()
|
renderer.resume()
|
||||||
|
|||||||
@@ -129,7 +129,15 @@ export function createDialogProviderOptions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (method.type === "api") {
|
if (method.type === "api") {
|
||||||
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
|
let metadata: Record<string, string> | undefined
|
||||||
|
if (method.prompts?.length) {
|
||||||
|
const value = await PromptsMethod({ dialog, prompts: method.prompts })
|
||||||
|
if (!value) return
|
||||||
|
metadata = value
|
||||||
|
}
|
||||||
|
return dialog.replace(() => (
|
||||||
|
<ApiMethod providerID={provider.id} title={method.label} metadata={metadata} />
|
||||||
|
))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -249,6 +257,7 @@ function CodeMethod(props: CodeMethodProps) {
|
|||||||
interface ApiMethodProps {
|
interface ApiMethodProps {
|
||||||
providerID: string
|
providerID: string
|
||||||
title: string
|
title: string
|
||||||
|
metadata?: Record<string, string>
|
||||||
}
|
}
|
||||||
function ApiMethod(props: ApiMethodProps) {
|
function ApiMethod(props: ApiMethodProps) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -293,6 +302,7 @@ function ApiMethod(props: ApiMethodProps) {
|
|||||||
auth: {
|
auth: {
|
||||||
type: "api",
|
type: "api",
|
||||||
key: value,
|
key: value,
|
||||||
|
...(props.metadata ? { metadata: props.metadata } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await sdk.client.instance.dispose()
|
await sdk.client.instance.dispose()
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { usePromptStash } from "./stash"
|
|||||||
import { DialogStash } from "../dialog-stash"
|
import { DialogStash } from "../dialog-stash"
|
||||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||||
import { useCommandDialog } from "../dialog-command"
|
import { useCommandDialog } from "../dialog-command"
|
||||||
import { useKeyboard, useRenderer, type JSX } from "@opentui/solid"
|
import { useRenderer, type JSX } from "@opentui/solid"
|
||||||
import { Editor } from "@tui/util/editor"
|
import { Editor } from "@tui/util/editor"
|
||||||
import { useExit } from "../../context/exit"
|
import { useExit } from "../../context/exit"
|
||||||
import { Clipboard } from "../../util/clipboard"
|
import { Clipboard } from "../../util/clipboard"
|
||||||
@@ -400,20 +400,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
// Windows Terminal 1.25+ handles Ctrl+V on keydown when kitty events are
|
|
||||||
// enabled, but still reports the kitty key-release event. Probe on release.
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
useKeyboard(
|
|
||||||
(evt) => {
|
|
||||||
if (!input.focused) return
|
|
||||||
if (evt.name === "v" && evt.ctrl && evt.eventType === "release") {
|
|
||||||
command.trigger("prompt.paste")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ release: true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ref: PromptRef = {
|
const ref: PromptRef = {
|
||||||
get focused() {
|
get focused() {
|
||||||
return input.focused
|
return input.focused
|
||||||
@@ -841,20 +827,8 @@ export function Prompt(props: PromptProps) {
|
|||||||
return !!current
|
return !!current
|
||||||
})
|
})
|
||||||
|
|
||||||
const suggestion = createMemo(() => {
|
|
||||||
if (!props.sessionID) return
|
|
||||||
if (store.mode !== "normal") return
|
|
||||||
if (store.prompt.input) return
|
|
||||||
const current = status()
|
|
||||||
if (current.type !== "idle") return
|
|
||||||
const value = current.suggestion?.trim()
|
|
||||||
if (!value) return
|
|
||||||
return value
|
|
||||||
})
|
|
||||||
|
|
||||||
const placeholderText = createMemo(() => {
|
const placeholderText = createMemo(() => {
|
||||||
if (props.showPlaceholder === false) return undefined
|
if (props.showPlaceholder === false) return undefined
|
||||||
if (suggestion()) return suggestion()
|
|
||||||
if (store.mode === "shell") {
|
if (store.mode === "shell") {
|
||||||
if (!shell().length) return undefined
|
if (!shell().length) return undefined
|
||||||
const example = shell()[store.placeholder % shell().length]
|
const example = shell()[store.placeholder % shell().length]
|
||||||
@@ -945,16 +919,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!store.prompt.input && e.name === "right" && !e.ctrl && !e.meta && !e.shift && !e.super) {
|
|
||||||
const value = suggestion()
|
|
||||||
if (value) {
|
|
||||||
input.setText(value)
|
|
||||||
setStore("prompt", "input", value)
|
|
||||||
input.gotoBufferEnd()
|
|
||||||
e.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Check clipboard for images before terminal-handled paste runs.
|
// Check clipboard for images before terminal-handled paste runs.
|
||||||
// This helps terminals that forward Ctrl+V to the app; Windows
|
// This helps terminals that forward Ctrl+V to the app; Windows
|
||||||
// Terminal 1.25+ usually handles Ctrl+V before this path.
|
// Terminal 1.25+ usually handles Ctrl+V before this path.
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "session.status": {
|
case "session.status": {
|
||||||
setStore("session_status", event.properties.sessionID, reconcile(event.properties.status))
|
setStore("session_status", event.properties.sessionID, event.properties.status)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,5 +148,7 @@ const TIPS = [
|
|||||||
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs",
|
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs",
|
||||||
"Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog",
|
"Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog",
|
||||||
"Use {highlight}/rename{/highlight} to rename the current session",
|
"Use {highlight}/rename{/highlight} to rename the current session",
|
||||||
"Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell",
|
...(process.platform === "win32"
|
||||||
|
? ["Press {highlight}Ctrl+Z{/highlight} to undo changes in your prompt"]
|
||||||
|
: ["Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell"]),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -111,7 +111,15 @@ export namespace TuiConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
|
const keybinds = { ...(acc.result.keybinds ?? {}) }
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
|
||||||
|
keybinds.terminal_suspend = "none"
|
||||||
|
keybinds.input_undo ??= unique(["ctrl+z", ...Config.Keybinds.shape.input_undo.parse(undefined).split(",")]).join(
|
||||||
|
",",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
acc.result.keybinds = Config.Keybinds.parse(keybinds)
|
||||||
|
|
||||||
const deps: Promise<void>[] = []
|
const deps: Promise<void>[] = []
|
||||||
if (acc.result.plugin?.length) {
|
if (acc.result.plugin?.length) {
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ export namespace Flag {
|
|||||||
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
|
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
|
||||||
export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES")
|
export const OPENCODE_EXPERIMENTAL_WORKSPACES = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES")
|
||||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||||
export const OPENCODE_EXPERIMENTAL_NEXT_PROMPT = truthy("OPENCODE_EXPERIMENTAL_NEXT_PROMPT")
|
|
||||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||||
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
||||||
export const OPENCODE_DISABLE_EMBEDDED_WEB_UI = truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI")
|
export const OPENCODE_DISABLE_EMBEDDED_WEB_UI = truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI")
|
||||||
|
|||||||
@@ -105,7 +105,17 @@ export namespace LSPServer {
|
|||||||
if (!tsserver) return
|
if (!tsserver) return
|
||||||
const bin = await Npm.which("typescript-language-server")
|
const bin = await Npm.which("typescript-language-server")
|
||||||
if (!bin) return
|
if (!bin) return
|
||||||
const proc = spawn(bin, ["--stdio"], {
|
|
||||||
|
const args = ["--stdio", "--tsserver-log-verbosity", "off", "--tsserver-path", tsserver]
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(await pathExists(path.join(root, "tsconfig.json"))) &&
|
||||||
|
!(await pathExists(path.join(root, "jsconfig.json")))
|
||||||
|
) {
|
||||||
|
args.push("--ignore-node-modules")
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = spawn(bin, args, {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
|
export async function CloudflareWorkersAuthPlugin(_input: PluginInput): Promise<Hooks> {
|
||||||
|
const prompts = [
|
||||||
|
...(!process.env.CLOUDFLARE_ACCOUNT_ID
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
key: "accountId",
|
||||||
|
message: "Enter your Cloudflare Account ID",
|
||||||
|
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
auth: {
|
||||||
|
provider: "cloudflare-workers-ai",
|
||||||
|
methods: [
|
||||||
|
{
|
||||||
|
type: "api",
|
||||||
|
label: "API key",
|
||||||
|
prompts,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function CloudflareAIGatewayAuthPlugin(_input: PluginInput): Promise<Hooks> {
|
||||||
|
const prompts = [
|
||||||
|
...(!process.env.CLOUDFLARE_ACCOUNT_ID
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
key: "accountId",
|
||||||
|
message: "Enter your Cloudflare Account ID",
|
||||||
|
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(!process.env.CLOUDFLARE_GATEWAY_ID
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
key: "gatewayId",
|
||||||
|
message: "Enter your Cloudflare AI Gateway ID",
|
||||||
|
placeholder: "e.g. my-gateway",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
auth: {
|
||||||
|
provider: "cloudflare-ai-gateway",
|
||||||
|
methods: [
|
||||||
|
{
|
||||||
|
type: "api",
|
||||||
|
label: "Gateway API token",
|
||||||
|
prompts,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { NamedError } from "@opencode-ai/util/error"
|
|||||||
import { CopilotAuthPlugin } from "./github-copilot/copilot"
|
import { CopilotAuthPlugin } from "./github-copilot/copilot"
|
||||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
||||||
import { PoeAuthPlugin } from "opencode-poe-auth"
|
import { PoeAuthPlugin } from "opencode-poe-auth"
|
||||||
|
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
|
||||||
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
@@ -46,7 +47,14 @@ export namespace Plugin {
|
|||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {}
|
||||||
|
|
||||||
// Built-in plugins that are directly imported (not installed from npm)
|
// Built-in plugins that are directly imported (not installed from npm)
|
||||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin]
|
const INTERNAL_PLUGINS: PluginInstance[] = [
|
||||||
|
CodexAuthPlugin,
|
||||||
|
CopilotAuthPlugin,
|
||||||
|
GitlabAuthPlugin,
|
||||||
|
PoeAuthPlugin,
|
||||||
|
CloudflareWorkersAuthPlugin,
|
||||||
|
CloudflareAIGatewayAuthPlugin,
|
||||||
|
]
|
||||||
|
|
||||||
function isServerPlugin(value: unknown): value is PluginInstance {
|
function isServerPlugin(value: unknown): value is PluginInstance {
|
||||||
return typeof value === "function"
|
return typeof value === "function"
|
||||||
|
|||||||
@@ -672,13 +672,26 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
"cloudflare-workers-ai": Effect.fnUntraced(function* (input: Info) {
|
"cloudflare-workers-ai": Effect.fnUntraced(function* (input: Info) {
|
||||||
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
|
// When baseURL is already configured (e.g. corporate config routing through a proxy/gateway),
|
||||||
if (!accountId) return { autoload: false }
|
// skip the account ID check because the URL is already fully specified.
|
||||||
|
if (input.options?.baseURL) return { autoload: false }
|
||||||
|
|
||||||
|
const auth = yield* dep.auth(input.id)
|
||||||
|
const accountId =
|
||||||
|
Env.get("CLOUDFLARE_ACCOUNT_ID") || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
|
||||||
|
if (!accountId)
|
||||||
|
return {
|
||||||
|
autoload: false,
|
||||||
|
async getModel() {
|
||||||
|
throw new Error(
|
||||||
|
"CLOUDFLARE_ACCOUNT_ID is missing. Set it with: export CLOUDFLARE_ACCOUNT_ID=<your-account-id>",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const apiKey = yield* Effect.gen(function* () {
|
const apiKey = yield* Effect.gen(function* () {
|
||||||
const envToken = Env.get("CLOUDFLARE_API_KEY")
|
const envToken = Env.get("CLOUDFLARE_API_KEY")
|
||||||
if (envToken) return envToken
|
if (envToken) return envToken
|
||||||
const auth = yield* dep.auth(input.id)
|
|
||||||
if (auth?.type === "api") return auth.key
|
if (auth?.type === "api") return auth.key
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
@@ -702,16 +715,34 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
"cloudflare-ai-gateway": Effect.fnUntraced(function* (input: Info) {
|
"cloudflare-ai-gateway": Effect.fnUntraced(function* (input: Info) {
|
||||||
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
|
// When baseURL is already configured (e.g. corporate config), skip the ID checks.
|
||||||
const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
|
if (input.options?.baseURL) return { autoload: false }
|
||||||
|
|
||||||
if (!accountId || !gateway) return { autoload: false }
|
const auth = yield* dep.auth(input.id)
|
||||||
|
const accountId =
|
||||||
|
Env.get("CLOUDFLARE_ACCOUNT_ID") || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
|
||||||
|
const gateway =
|
||||||
|
Env.get("CLOUDFLARE_GATEWAY_ID") || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined)
|
||||||
|
|
||||||
|
if (!accountId || !gateway) {
|
||||||
|
const missing = [
|
||||||
|
!accountId ? "CLOUDFLARE_ACCOUNT_ID" : undefined,
|
||||||
|
!gateway ? "CLOUDFLARE_GATEWAY_ID" : undefined,
|
||||||
|
].filter((x): x is string => Boolean(x))
|
||||||
|
return {
|
||||||
|
autoload: false,
|
||||||
|
async getModel() {
|
||||||
|
throw new Error(
|
||||||
|
`${missing.join(" and ")} missing. Set with: ${missing.map((x) => `export ${x}=<value>`).join(" && ")}`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get API token from env or auth - required for authenticated gateways
|
// Get API token from env or auth - required for authenticated gateways
|
||||||
const apiToken = yield* Effect.gen(function* () {
|
const apiToken = yield* Effect.gen(function* () {
|
||||||
const envToken = Env.get("CLOUDFLARE_API_TOKEN") || Env.get("CF_AIG_TOKEN")
|
const envToken = Env.get("CLOUDFLARE_API_TOKEN") || Env.get("CF_AIG_TOKEN")
|
||||||
if (envToken) return envToken
|
if (envToken) return envToken
|
||||||
const auth = yield* dep.auth(input.id)
|
|
||||||
if (auth?.type === "api") return auth.key
|
if (auth?.type === "api") return auth.key
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -936,6 +936,12 @@ export namespace ProviderTransform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const key = sdkKey(model.api.npm) ?? model.providerID
|
const key = sdkKey(model.api.npm) ?? model.providerID
|
||||||
|
// @ai-sdk/azure delegates to OpenAIChatLanguageModel which reads from
|
||||||
|
// providerOptions["openai"], but OpenAIResponsesLanguageModel checks
|
||||||
|
// "azure" first. Pass both so model options work on either code path.
|
||||||
|
if (model.api.npm === "@ai-sdk/azure") {
|
||||||
|
return { openai: options, azure: options }
|
||||||
|
}
|
||||||
return { [key]: options }
|
return { [key]: options }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { Plugin } from "../plugin"
|
|||||||
import PROMPT_PLAN from "../session/prompt/plan.txt"
|
import PROMPT_PLAN from "../session/prompt/plan.txt"
|
||||||
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
|
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
|
||||||
import MAX_STEPS from "../session/prompt/max-steps.txt"
|
import MAX_STEPS from "../session/prompt/max-steps.txt"
|
||||||
import PROMPT_SUGGEST_NEXT from "../session/prompt/suggest-next.txt"
|
|
||||||
import { ToolRegistry } from "../tool/registry"
|
import { ToolRegistry } from "../tool/registry"
|
||||||
import { Runner } from "@/effect/runner"
|
import { Runner } from "@/effect/runner"
|
||||||
import { MCP } from "../mcp"
|
import { MCP } from "../mcp"
|
||||||
@@ -250,80 +249,6 @@ export namespace SessionPrompt {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const suggest = Effect.fn("SessionPrompt.suggest")(function* (input: {
|
|
||||||
session: Session.Info
|
|
||||||
sessionID: SessionID
|
|
||||||
message: MessageV2.WithParts
|
|
||||||
}) {
|
|
||||||
if (input.session.parentID) return
|
|
||||||
const message = input.message.info
|
|
||||||
if (message.role !== "assistant") return
|
|
||||||
if (message.error) return
|
|
||||||
if (!message.finish) return
|
|
||||||
if (["tool-calls", "unknown"].includes(message.finish)) return
|
|
||||||
if ((yield* status.get(input.sessionID)).type !== "idle") return
|
|
||||||
|
|
||||||
// Use the same model for prompt-cache hit on the conversation prefix
|
|
||||||
const model = yield* Effect.promise(async () =>
|
|
||||||
Provider.getModel(message.providerID, message.modelID).catch(() => undefined),
|
|
||||||
)
|
|
||||||
if (!model) return
|
|
||||||
|
|
||||||
const ag = yield* agents.get(message.agent ?? "code")
|
|
||||||
if (!ag) return
|
|
||||||
|
|
||||||
// Full message history so the cached KV from the main conversation is reused
|
|
||||||
const msgs = yield* MessageV2.filterCompactedEffect(input.sessionID)
|
|
||||||
const real = (item: MessageV2.WithParts) =>
|
|
||||||
item.info.role === "user" && !item.parts.every((part) => "synthetic" in part && part.synthetic)
|
|
||||||
const parent = msgs.find((item) => item.info.id === message.parentID)
|
|
||||||
const user = parent && real(parent) ? parent.info : msgs.findLast((item) => real(item))?.info
|
|
||||||
if (!user || user.role !== "user") return
|
|
||||||
|
|
||||||
// Rebuild system prompt identical to the main loop for cache hit
|
|
||||||
const skills = yield* Effect.promise(() => SystemPrompt.skills(ag))
|
|
||||||
const env = yield* Effect.promise(() => SystemPrompt.environment(model))
|
|
||||||
const instructions = yield* instruction.system().pipe(Effect.orDie)
|
|
||||||
const modelMsgs = yield* Effect.promise(() => MessageV2.toModelMessages(msgs, model))
|
|
||||||
const system = [...env, ...(skills ? [skills] : []), ...instructions]
|
|
||||||
|
|
||||||
const text = yield* Effect.promise(async (signal) => {
|
|
||||||
const result = await LLM.stream({
|
|
||||||
agent: ag,
|
|
||||||
user,
|
|
||||||
system,
|
|
||||||
small: false,
|
|
||||||
tools: {},
|
|
||||||
model,
|
|
||||||
abort: signal,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
retries: 1,
|
|
||||||
toolChoice: "none",
|
|
||||||
// Append suggestion instruction after the full conversation
|
|
||||||
messages: [...modelMsgs, { role: "user" as const, content: PROMPT_SUGGEST_NEXT }],
|
|
||||||
})
|
|
||||||
return result.text
|
|
||||||
})
|
|
||||||
|
|
||||||
const line = text
|
|
||||||
.replace(/<think>[\s\S]*?<\/think>\s*/g, "")
|
|
||||||
.split("\n")
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.find((item) => item.length > 0)
|
|
||||||
?.replace(/^["'`]+|["'`]+$/g, "")
|
|
||||||
if (!line) return
|
|
||||||
|
|
||||||
const tag = line
|
|
||||||
.toUpperCase()
|
|
||||||
.replace(/[\s-]+/g, "_")
|
|
||||||
.replace(/[^A-Z_]/g, "")
|
|
||||||
if (tag === "NO_SUGGESTION") return
|
|
||||||
|
|
||||||
const suggestion = line.length > 110 ? line.slice(0, 107) + "..." : line
|
|
||||||
if ((yield* status.get(input.sessionID)).type !== "idle") return
|
|
||||||
yield* status.suggest(input.sessionID, suggestion)
|
|
||||||
})
|
|
||||||
|
|
||||||
const insertReminders = Effect.fn("SessionPrompt.insertReminders")(function* (input: {
|
const insertReminders = Effect.fn("SessionPrompt.insertReminders")(function* (input: {
|
||||||
messages: MessageV2.WithParts[]
|
messages: MessageV2.WithParts[]
|
||||||
agent: Agent.Info
|
agent: Agent.Info
|
||||||
@@ -1394,13 +1319,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (input.noReply === true) return message
|
if (input.noReply === true) return message
|
||||||
const result = yield* loop({ sessionID: input.sessionID })
|
return yield* loop({ sessionID: input.sessionID })
|
||||||
yield* suggest({
|
|
||||||
session,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
message: result,
|
|
||||||
}).pipe(Effect.ignore, Effect.forkIn(scope))
|
|
||||||
return result
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
You are generating a suggested next user message for the current conversation.
|
|
||||||
|
|
||||||
Goal:
|
|
||||||
- Suggest a useful next step that keeps momentum.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Output exactly one line, 110 characters max. Be concise.
|
|
||||||
- Write as the user speaking to the assistant (for example: "Can you...", "Help me...", "Let's...").
|
|
||||||
- Match the user's tone and language; keep it natural and human.
|
|
||||||
- Prefer a concrete action over a broad question.
|
|
||||||
- If the conversation is vague or small-talk, steer toward a practical starter request.
|
|
||||||
- If there is no meaningful or appropriate next step to suggest, output exactly: NO_SUGGESTION
|
|
||||||
- Avoid corporate or robotic phrasing.
|
|
||||||
- Avoid asking multiple discovery questions in one sentence.
|
|
||||||
- Do not include quotes, labels, markdown, or explanations.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- Greeting context -> "Can you scan this repo and suggest the best first task to tackle?"
|
|
||||||
- Bug-fix context -> "Can you reproduce this bug and propose the smallest safe fix?"
|
|
||||||
- Feature context -> "Let's implement this incrementally; start with the MVP version first."
|
|
||||||
- Conversation is complete -> "NO_SUGGESTION"
|
|
||||||
@@ -11,7 +11,6 @@ export namespace SessionStatus {
|
|||||||
.union([
|
.union([
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal("idle"),
|
type: z.literal("idle"),
|
||||||
suggestion: z.string().optional(),
|
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal("retry"),
|
type: z.literal("retry"),
|
||||||
@@ -49,7 +48,6 @@ export namespace SessionStatus {
|
|||||||
readonly get: (sessionID: SessionID) => Effect.Effect<Info>
|
readonly get: (sessionID: SessionID) => Effect.Effect<Info>
|
||||||
readonly list: () => Effect.Effect<Map<SessionID, Info>>
|
readonly list: () => Effect.Effect<Map<SessionID, Info>>
|
||||||
readonly set: (sessionID: SessionID, status: Info) => Effect.Effect<void>
|
readonly set: (sessionID: SessionID, status: Info) => Effect.Effect<void>
|
||||||
readonly suggest: (sessionID: SessionID, suggestion: string) => Effect.Effect<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionStatus") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionStatus") {}
|
||||||
@@ -83,17 +81,7 @@ export namespace SessionStatus {
|
|||||||
data.set(sessionID, status)
|
data.set(sessionID, status)
|
||||||
})
|
})
|
||||||
|
|
||||||
const suggest = Effect.fn("SessionStatus.suggest")(function* (sessionID: SessionID, suggestion: string) {
|
return Service.of({ get, list, set })
|
||||||
const data = yield* InstanceState.get(state)
|
|
||||||
const current = data.get(sessionID)
|
|
||||||
if (current && current.type !== "idle") return
|
|
||||||
const status: Info = { type: "idle", suggestion }
|
|
||||||
// only publish Status so the TUI sees the suggestion;
|
|
||||||
// skip Event.Idle to avoid spurious plugin notifications
|
|
||||||
yield* bus.publish(Event.Status, { sessionID, status })
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ get, list, set, suggest })
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -111,8 +99,4 @@ export namespace SessionStatus {
|
|||||||
export async function set(sessionID: SessionID, status: Info) {
|
export async function set(sessionID: SessionID, status: Info) {
|
||||||
return runPromise((svc) => svc.set(sessionID, status))
|
return runPromise((svc) => svc.set(sessionID, status))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function suggest(sessionID: SessionID, suggestion: string) {
|
|
||||||
return runPromise((svc) => svc.suggest(sessionID, suggestion))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Global } from "../../src/global"
|
|||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
|
|
||||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||||
|
const wintest = process.platform === "win32" ? test : test.skip
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await Config.invalidate(true)
|
await Config.invalidate(true)
|
||||||
@@ -441,6 +442,53 @@ test("merges keybind overrides across precedence layers", async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
wintest("defaults Ctrl+Z to input undo on Windows", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const config = await TuiConfig.get()
|
||||||
|
expect(config.keybinds?.terminal_suspend).toBe("none")
|
||||||
|
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
wintest("keeps explicit input undo overrides on Windows", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { input_undo: "ctrl+y" } }))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const config = await TuiConfig.get()
|
||||||
|
expect(config.keybinds?.terminal_suspend).toBe("none")
|
||||||
|
expect(config.keybinds?.input_undo).toBe("ctrl+y")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
wintest("ignores terminal suspend bindings on Windows", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { terminal_suspend: "alt+z" } }))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const config = await TuiConfig.get()
|
||||||
|
expect(config.keybinds?.terminal_suspend).toBe("none")
|
||||||
|
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => {
|
test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, expect, spyOn, test } from "bun:test"
|
import { describe, expect, spyOn, test } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import fs from "fs/promises"
|
||||||
import * as Lsp from "../../src/lsp/index"
|
import * as Lsp from "../../src/lsp/index"
|
||||||
|
import * as launch from "../../src/lsp/launch"
|
||||||
import { LSPServer } from "../../src/lsp/server"
|
import { LSPServer } from "../../src/lsp/server"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
@@ -52,4 +54,80 @@ describe("lsp.spawn", () => {
|
|||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("spawns builtin Typescript LSP with correct arguments", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
|
||||||
|
// Create dummy tsserver to satisfy Module.resolve
|
||||||
|
const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib")
|
||||||
|
await fs.mkdir(tsdk, { recursive: true })
|
||||||
|
await fs.writeFile(path.join(tsdk, "tsserver.js"), "")
|
||||||
|
|
||||||
|
const spawnSpy = spyOn(launch, "spawn").mockImplementation(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
stdin: {},
|
||||||
|
stdout: {},
|
||||||
|
stderr: {},
|
||||||
|
on: () => {},
|
||||||
|
kill: () => {},
|
||||||
|
}) as any,
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
await LSPServer.Typescript.spawn(tmp.path)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(spawnSpy).toHaveBeenCalled()
|
||||||
|
const args = spawnSpy.mock.calls[0][1] as string[]
|
||||||
|
|
||||||
|
expect(args).toContain("--tsserver-path")
|
||||||
|
expect(args).toContain("--tsserver-log-verbosity")
|
||||||
|
expect(args).toContain("off")
|
||||||
|
} finally {
|
||||||
|
spawnSpy.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("spawns builtin Typescript LSP with --ignore-node-modules if no config is found", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
|
||||||
|
// Create dummy tsserver to satisfy Module.resolve
|
||||||
|
const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib")
|
||||||
|
await fs.mkdir(tsdk, { recursive: true })
|
||||||
|
await fs.writeFile(path.join(tsdk, "tsserver.js"), "")
|
||||||
|
|
||||||
|
// NO tsconfig.json or jsconfig.json created here
|
||||||
|
|
||||||
|
const spawnSpy = spyOn(launch, "spawn").mockImplementation(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
stdin: {},
|
||||||
|
stdout: {},
|
||||||
|
stderr: {},
|
||||||
|
on: () => {},
|
||||||
|
kill: () => {},
|
||||||
|
}) as any,
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
await LSPServer.Typescript.spawn(tmp.path)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(spawnSpy).toHaveBeenCalled()
|
||||||
|
const args = spawnSpy.mock.calls[0][1] as string[]
|
||||||
|
|
||||||
|
expect(args).toContain("--ignore-node-modules")
|
||||||
|
} finally {
|
||||||
|
spawnSpy.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ export type EventPermissionReplied = {
|
|||||||
export type SessionStatus =
|
export type SessionStatus =
|
||||||
| {
|
| {
|
||||||
type: "idle"
|
type: "idle"
|
||||||
suggestion?: string
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "retry"
|
type: "retry"
|
||||||
@@ -1640,6 +1639,9 @@ export type OAuth = {
|
|||||||
export type ApiAuth = {
|
export type ApiAuth = {
|
||||||
type: "api"
|
type: "api"
|
||||||
key: string
|
key: string
|
||||||
|
metadata?: {
|
||||||
|
[key: string]: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WellKnownAuth = {
|
export type WellKnownAuth = {
|
||||||
|
|||||||
@@ -11621,6 +11621,15 @@
|
|||||||
},
|
},
|
||||||
"key": {
|
"key": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"type": "object",
|
||||||
|
"propertyNames": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["type", "key"]
|
"required": ["type", "key"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -7,6 +7,21 @@
|
|||||||
gap: 0px;
|
gap: 0px;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
&[data-clickable="true"] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-hide-details="true"] {
|
||||||
|
[data-slot="basic-tool-tool-trigger-content"] {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="basic-tool-tool-info"] {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[data-slot="basic-tool-tool-trigger-content"] {
|
[data-slot="basic-tool-tool-trigger-content"] {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
width: auto;
|
width: auto;
|
||||||
@@ -165,3 +180,83 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-component="task-tool-card"] {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-weak-base, rgba(255, 255, 255, 0.08));
|
||||||
|
background: color-mix(in srgb, var(--background-base) 92%, transparent);
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background-color 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
|
|
||||||
|
[data-slot="basic-tool-tool-info-structured"] {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="basic-tool-tool-info-main"] {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="task-tool-spinner"] {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
[data-component="spinner"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="task-tool-action"] {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--icon-weak);
|
||||||
|
margin-left: auto;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-4px);
|
||||||
|
transition:
|
||||||
|
opacity 0.15s ease,
|
||||||
|
transform 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="task-tool-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);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="basic-tool-tool-subtitle"] {
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
border-color: var(--border-weak-base, rgba(255, 255, 255, 0.08));
|
||||||
|
background: color-mix(in srgb, var(--background-stronger) 88%, transparent);
|
||||||
|
|
||||||
|
[data-component="task-tool-action"] {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export interface BasicToolProps {
|
|||||||
locked?: boolean
|
locked?: boolean
|
||||||
animated?: boolean
|
animated?: boolean
|
||||||
onSubtitleClick?: () => void
|
onSubtitleClick?: () => void
|
||||||
|
onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent>
|
||||||
|
triggerHref?: string
|
||||||
|
clickable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
|
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
|
||||||
@@ -121,74 +124,101 @@ export function BasicTool(props: BasicToolProps) {
|
|||||||
setState("open", value)
|
setState("open", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const trigger = () => (
|
||||||
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
|
<div
|
||||||
<Collapsible.Trigger>
|
data-component="tool-trigger"
|
||||||
<div data-component="tool-trigger">
|
data-clickable={props.clickable ? "true" : undefined}
|
||||||
<div data-slot="basic-tool-tool-trigger-content">
|
data-hide-details={props.hideDetails ? "true" : undefined}
|
||||||
<div data-slot="basic-tool-tool-info">
|
>
|
||||||
<Switch>
|
<div data-slot="basic-tool-tool-trigger-content">
|
||||||
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
|
<div data-slot="basic-tool-tool-info">
|
||||||
{(trigger) => (
|
<Switch>
|
||||||
<div data-slot="basic-tool-tool-info-structured">
|
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
|
||||||
<div data-slot="basic-tool-tool-info-main">
|
{(title) => (
|
||||||
|
<div data-slot="basic-tool-tool-info-structured">
|
||||||
|
<div data-slot="basic-tool-tool-info-main">
|
||||||
|
<span
|
||||||
|
data-slot="basic-tool-tool-title"
|
||||||
|
classList={{
|
||||||
|
[title().titleClass ?? ""]: !!title().titleClass,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextShimmer text={title().title} active={pending()} />
|
||||||
|
</span>
|
||||||
|
<Show when={!pending()}>
|
||||||
|
<Show when={title().subtitle}>
|
||||||
<span
|
<span
|
||||||
data-slot="basic-tool-tool-title"
|
data-slot="basic-tool-tool-subtitle"
|
||||||
classList={{
|
classList={{
|
||||||
[trigger().titleClass ?? ""]: !!trigger().titleClass,
|
[title().subtitleClass ?? ""]: !!title().subtitleClass,
|
||||||
|
clickable: !!props.onSubtitleClick,
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (props.onSubtitleClick) {
|
||||||
|
e.stopPropagation()
|
||||||
|
props.onSubtitleClick()
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TextShimmer text={trigger().title} active={pending()} />
|
{title().subtitle}
|
||||||
</span>
|
</span>
|
||||||
<Show when={!pending()}>
|
</Show>
|
||||||
<Show when={trigger().subtitle}>
|
<Show when={title().args?.length}>
|
||||||
|
<For each={title().args}>
|
||||||
|
{(arg) => (
|
||||||
<span
|
<span
|
||||||
data-slot="basic-tool-tool-subtitle"
|
data-slot="basic-tool-tool-arg"
|
||||||
classList={{
|
classList={{
|
||||||
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
|
[title().argsClass ?? ""]: !!title().argsClass,
|
||||||
clickable: !!props.onSubtitleClick,
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (props.onSubtitleClick) {
|
|
||||||
e.stopPropagation()
|
|
||||||
props.onSubtitleClick()
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{trigger().subtitle}
|
{arg}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
)}
|
||||||
<Show when={trigger().args?.length}>
|
</For>
|
||||||
<For each={trigger().args}>
|
|
||||||
{(arg) => (
|
|
||||||
<span
|
|
||||||
data-slot="basic-tool-tool-arg"
|
|
||||||
classList={{
|
|
||||||
[trigger().argsClass ?? ""]: !!trigger().argsClass,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{arg}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<Show when={!pending() && trigger().action}>
|
|
||||||
<span data-slot="basic-tool-tool-action">{trigger().action}</span>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</Show>
|
||||||
)}
|
</div>
|
||||||
</Match>
|
<Show when={!pending() && title().action}>
|
||||||
<Match when={true}>{props.trigger as JSX.Element}</Match>
|
<span data-slot="basic-tool-tool-action">{title().action}</span>
|
||||||
</Switch>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
|
</Match>
|
||||||
<Collapsible.Arrow />
|
<Match when={true}>{props.trigger as JSX.Element}</Match>
|
||||||
</Show>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
</Collapsible.Trigger>
|
</div>
|
||||||
|
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
|
||||||
|
<Collapsible.Arrow />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
|
||||||
|
<Show
|
||||||
|
when={props.triggerHref}
|
||||||
|
fallback={
|
||||||
|
<Collapsible.Trigger
|
||||||
|
data-hide-details={props.hideDetails ? "true" : undefined}
|
||||||
|
onClick={props.onTriggerClick}
|
||||||
|
>
|
||||||
|
{trigger()}
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(href) => (
|
||||||
|
<Collapsible.Trigger
|
||||||
|
as="a"
|
||||||
|
href={href()}
|
||||||
|
data-hide-details={props.hideDetails ? "true" : undefined}
|
||||||
|
onClick={props.onTriggerClick}
|
||||||
|
>
|
||||||
|
{trigger()}
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
<Show when={props.animated && props.children && !props.hideDetails}>
|
<Show when={props.animated && props.children && !props.hideDetails}>
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
|
|||||||
@@ -62,6 +62,11 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[data-hide-details="true"] {
|
||||||
|
height: auto;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
[data-slot="collapsible-arrow"] {
|
[data-slot="collapsible-arrow"] {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Message as MessageType,
|
Message as MessageType,
|
||||||
Part as PartType,
|
Part as PartType,
|
||||||
ReasoningPart,
|
ReasoningPart,
|
||||||
|
Session,
|
||||||
TextPart,
|
TextPart,
|
||||||
ToolPart,
|
ToolPart,
|
||||||
UserMessage,
|
UserMessage,
|
||||||
@@ -49,6 +50,7 @@ import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/pa
|
|||||||
import { checksum } from "@opencode-ai/util/encode"
|
import { checksum } from "@opencode-ai/util/encode"
|
||||||
import { Tooltip } from "./tooltip"
|
import { Tooltip } from "./tooltip"
|
||||||
import { IconButton } from "./icon-button"
|
import { IconButton } from "./icon-button"
|
||||||
|
import { Spinner } from "./spinner"
|
||||||
import { TextShimmer } from "./text-shimmer"
|
import { TextShimmer } from "./text-shimmer"
|
||||||
import { AnimatedCountList } from "./tool-count-summary"
|
import { AnimatedCountList } from "./tool-count-summary"
|
||||||
import { ToolStatusTitle } from "./tool-status-title"
|
import { ToolStatusTitle } from "./tool-status-title"
|
||||||
@@ -274,6 +276,47 @@ function agentTitle(i18n: UiI18n, type?: string) {
|
|||||||
return i18n.t("ui.tool.agent", { type })
|
return i18n.t("ui.tool.agent", { type })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const agentTones: Record<string, string> = {
|
||||||
|
ask: "var(--icon-agent-ask-base)",
|
||||||
|
build: "var(--icon-agent-build-base)",
|
||||||
|
docs: "var(--icon-agent-docs-base)",
|
||||||
|
plan: "var(--icon-agent-plan-base)",
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentPalette = [
|
||||||
|
"var(--icon-agent-ask-base)",
|
||||||
|
"var(--icon-agent-build-base)",
|
||||||
|
"var(--icon-agent-docs-base)",
|
||||||
|
"var(--icon-agent-plan-base)",
|
||||||
|
"var(--syntax-info)",
|
||||||
|
"var(--syntax-success)",
|
||||||
|
"var(--syntax-warning)",
|
||||||
|
"var(--syntax-property)",
|
||||||
|
"var(--syntax-constant)",
|
||||||
|
"var(--text-diff-add-base)",
|
||||||
|
"var(--text-diff-delete-base)",
|
||||||
|
"var(--icon-warning-base)",
|
||||||
|
]
|
||||||
|
|
||||||
|
function tone(name: string) {
|
||||||
|
let hash = 0
|
||||||
|
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
|
||||||
|
return agentPalette[hash % agentPalette.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskAgent(
|
||||||
|
raw: unknown,
|
||||||
|
list?: readonly { name: string; color?: string }[],
|
||||||
|
): { name?: string; color?: string } {
|
||||||
|
if (typeof raw !== "string" || !raw) return {}
|
||||||
|
const key = raw.toLowerCase()
|
||||||
|
const item = list?.find((entry) => entry.name === raw || entry.name.toLowerCase() === key)
|
||||||
|
return {
|
||||||
|
name: item?.name ?? `${raw[0]!.toUpperCase()}${raw.slice(1)}`,
|
||||||
|
color: item?.color ?? agentTones[key] ?? tone(key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getToolInfo(tool: string, input: any = {}): ToolInfo {
|
export function getToolInfo(tool: string, input: any = {}): ToolInfo {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
switch (tool) {
|
switch (tool) {
|
||||||
@@ -402,6 +445,27 @@ function sessionLink(id: string | undefined, path: string, href?: (id: string) =
|
|||||||
return `${path.slice(0, idx)}/session/${id}`
|
return `${path.slice(0, idx)}/session/${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentSession(path: string) {
|
||||||
|
return path.match(/\/session\/([^/?#]+)/)?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskSession(
|
||||||
|
input: Record<string, any>,
|
||||||
|
path: string,
|
||||||
|
sessions: Session[] | undefined,
|
||||||
|
agents?: readonly { name: string; color?: string }[],
|
||||||
|
) {
|
||||||
|
const parentID = currentSession(path)
|
||||||
|
if (!parentID) return
|
||||||
|
const description = typeof input.description === "string" ? input.description : ""
|
||||||
|
const agent = taskAgent(input.subagent_type, agents).name
|
||||||
|
return (sessions ?? [])
|
||||||
|
.filter((session) => session.parentID === parentID && !session.time?.archived)
|
||||||
|
.filter((session) => (description ? session.title.startsWith(description) : true))
|
||||||
|
.filter((session) => (agent ? session.title.includes(`@${agent}`) : true))
|
||||||
|
.sort((a, b) => (b.time.created ?? 0) - (a.time.created ?? 0))[0]?.id
|
||||||
|
}
|
||||||
|
|
||||||
const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
|
const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
|
||||||
const HIDDEN_TOOLS = new Set(["todowrite"])
|
const HIDDEN_TOOLS = new Set(["todowrite"])
|
||||||
|
|
||||||
@@ -1678,13 +1742,14 @@ ToolRegistry.register({
|
|||||||
const data = useData()
|
const data = useData()
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const childSessionId = () => props.metadata.sessionId as string | undefined
|
const childSessionId = createMemo(() => {
|
||||||
const type = createMemo(() => {
|
const value = props.metadata.sessionId
|
||||||
const raw = props.input.subagent_type
|
if (typeof value === "string" && value) return value
|
||||||
if (typeof raw !== "string" || !raw) return undefined
|
return taskSession(props.input, location.pathname, data.store.session, data.store.agent)
|
||||||
return raw[0]!.toUpperCase() + raw.slice(1)
|
|
||||||
})
|
})
|
||||||
const title = createMemo(() => agentTitle(i18n, type()))
|
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 subtitle = createMemo(() => {
|
const subtitle = createMemo(() => {
|
||||||
const value = props.input.description
|
const value = props.input.description
|
||||||
if (typeof value === "string" && value) return value
|
if (typeof value === "string" && value) return value
|
||||||
@@ -1693,37 +1758,62 @@ ToolRegistry.register({
|
|||||||
const running = createMemo(() => props.status === "pending" || props.status === "running")
|
const running = createMemo(() => props.status === "pending" || props.status === "running")
|
||||||
|
|
||||||
const href = createMemo(() => sessionLink(childSessionId(), location.pathname, data.sessionHref))
|
const href = createMemo(() => sessionLink(childSessionId(), location.pathname, data.sessionHref))
|
||||||
|
const clickable = createMemo(() => !!(childSessionId() && (data.navigateToSession || href())))
|
||||||
|
|
||||||
const titleContent = () => <TextShimmer text={title()} active={running()} />
|
const open = () => {
|
||||||
|
const id = childSessionId()
|
||||||
|
if (!id) return
|
||||||
|
if (data.navigateToSession) {
|
||||||
|
data.navigateToSession(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const value = href()
|
||||||
|
if (value) window.location.assign(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigate = (event: MouseEvent) => {
|
||||||
|
if (!data.navigateToSession) return
|
||||||
|
if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
||||||
|
event.preventDefault()
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
|
||||||
const trigger = () => (
|
const trigger = () => (
|
||||||
<div data-slot="basic-tool-tool-info-structured">
|
<div data-component="task-tool-card">
|
||||||
<div data-slot="basic-tool-tool-info-main">
|
<div data-slot="basic-tool-tool-info-structured">
|
||||||
<span data-slot="basic-tool-tool-title" class="capitalize agent-title">
|
<div data-slot="basic-tool-tool-info-main">
|
||||||
{titleContent()}
|
<Show when={running()}>
|
||||||
</span>
|
<span data-component="task-tool-spinner" style={{ color: tone() ?? "var(--icon-interactive-base)" }}>
|
||||||
<Show when={subtitle()}>
|
<Spinner />
|
||||||
<Switch>
|
</span>
|
||||||
<Match when={href()}>
|
</Show>
|
||||||
<a
|
<span data-component="task-tool-title" style={{ color: tone() ?? "var(--text-strong)" }}>
|
||||||
data-slot="basic-tool-tool-subtitle"
|
{title()}
|
||||||
class="clickable subagent-link"
|
</span>
|
||||||
href={href()!}
|
<Show when={subtitle()}>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
|
||||||
>
|
</Show>
|
||||||
{subtitle()}
|
</div>
|
||||||
</a>
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={clickable()}>
|
||||||
|
<div data-component="task-tool-action">
|
||||||
|
<Icon name="square-arrow-top-right" size="small" />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
return <BasicTool icon="task" status={props.status} trigger={trigger()} hideDetails />
|
return (
|
||||||
|
<BasicTool
|
||||||
|
icon="task"
|
||||||
|
status={props.status}
|
||||||
|
trigger={trigger()}
|
||||||
|
hideDetails
|
||||||
|
triggerHref={href()}
|
||||||
|
clickable={clickable()}
|
||||||
|
onTriggerClick={navigate}
|
||||||
|
/>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { createSimpleContext } from "./helper"
|
|||||||
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||||
|
|
||||||
type Data = {
|
type Data = {
|
||||||
|
agent?: {
|
||||||
|
name: string
|
||||||
|
color?: string
|
||||||
|
}[]
|
||||||
provider?: ProviderListResponse
|
provider?: ProviderListResponse
|
||||||
session: Session[]
|
session: Session[]
|
||||||
session_status: {
|
session_status: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
|
|||||||
| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations |
|
| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations |
|
||||||
| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime |
|
| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime |
|
||||||
| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs |
|
| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs |
|
||||||
|
| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers |
|
||||||
| [opencode-morph-plugin](https://github.com/morphllm/opencode-morph-plugin) | Fast Apply editing, WarpGrep codebase search, and context compaction via Morph |
|
| [opencode-morph-plugin](https://github.com/morphllm/opencode-morph-plugin) | Fast Apply editing, WarpGrep codebase search, and context compaction via Morph |
|
||||||
| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible |
|
| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible |
|
||||||
| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions |
|
| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions |
|
||||||
@@ -42,6 +43,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
|
|||||||
| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Interactive plan review with visual annotation and private/offline sharing |
|
| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Interactive plan review with visual annotation and private/offline sharing |
|
||||||
| [@openspoon/subtask2](https://github.com/spoons-and-mirrors/subtask2) | Extend opencode /commands into a powerful orchestration system with granular flow control |
|
| [@openspoon/subtask2](https://github.com/spoons-and-mirrors/subtask2) | Extend opencode /commands into a powerful orchestration system with granular flow control |
|
||||||
| [opencode-scheduler](https://github.com/different-ai/opencode-scheduler) | Schedule recurring jobs using launchd (Mac) or systemd (Linux) with cron syntax |
|
| [opencode-scheduler](https://github.com/different-ai/opencode-scheduler) | Schedule recurring jobs using launchd (Mac) or systemd (Linux) with cron syntax |
|
||||||
|
| [opencode-conductor](https://github.com/derekbar90/opencode-conductor) | Protocol-Driven Workflow: Automation of the Context -> Spec -> Plan -> Implement lifecycle. |
|
||||||
| [micode](https://github.com/vtemian/micode) | Structured Brainstorm → Plan → Implement workflow with session continuity |
|
| [micode](https://github.com/vtemian/micode) | Structured Brainstorm → Plan → Implement workflow with session continuity |
|
||||||
| [octto](https://github.com/vtemian/octto) | Interactive browser UI for AI brainstorming with multi-question forms |
|
| [octto](https://github.com/vtemian/octto) | Interactive browser UI for AI brainstorming with multi-question forms |
|
||||||
| [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents) | Claude Code-style background agents with async delegation and context persistence |
|
| [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents) | Claude Code-style background agents with async delegation and context persistence |
|
||||||
|
|||||||
@@ -490,37 +490,42 @@ If you encounter "I'm sorry, but I cannot assist with that request" errors, try
|
|||||||
|
|
||||||
Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI, and more through a unified endpoint. With [Unified Billing](https://developers.cloudflare.com/ai-gateway/features/unified-billing/) you don't need separate API keys for each provider.
|
Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI, and more through a unified endpoint. With [Unified Billing](https://developers.cloudflare.com/ai-gateway/features/unified-billing/) you don't need separate API keys for each provider.
|
||||||
|
|
||||||
1. Head over to the [Cloudflare dashboard](https://dash.cloudflare.com/), navigate to **AI** > **AI Gateway**, and create a new gateway.
|
1. Head over to the [Cloudflare dashboard](https://dash.cloudflare.com/), navigate to **AI** > **AI Gateway**, and create a new gateway. Note your **Account ID** and **Gateway ID**.
|
||||||
|
|
||||||
2. Set your Account ID and Gateway ID as environment variables.
|
2. Run the `/connect` command and search for **Cloudflare AI Gateway**.
|
||||||
|
|
||||||
```bash title="~/.bash_profile"
|
|
||||||
export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id
|
|
||||||
export CLOUDFLARE_GATEWAY_ID=your-gateway-id
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Run the `/connect` command and search for **Cloudflare AI Gateway**.
|
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
/connect
|
/connect
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Enter your Cloudflare API token.
|
3. Enter your **Account ID** when prompted.
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
┌ API key
|
┌ Enter your Cloudflare Account ID
|
||||||
│
|
│
|
||||||
│
|
│
|
||||||
└ enter
|
└ enter
|
||||||
```
|
```
|
||||||
|
|
||||||
Or set it as an environment variable.
|
4. Enter your **Gateway ID** when prompted.
|
||||||
|
|
||||||
```bash title="~/.bash_profile"
|
```txt
|
||||||
export CLOUDFLARE_API_TOKEN=your-api-token
|
┌ Enter your Cloudflare AI Gateway ID
|
||||||
|
│
|
||||||
|
│
|
||||||
|
└ enter
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Run the `/models` command to select a model.
|
5. Enter your **Cloudflare API token**.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
┌ Gateway API token
|
||||||
|
│
|
||||||
|
│
|
||||||
|
└ enter
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Run the `/models` command to select a model.
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
/models
|
/models
|
||||||
@@ -542,27 +547,38 @@ Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI,
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Alternatively, you can set environment variables instead of using `/connect`.
|
||||||
|
|
||||||
|
```bash title="~/.bash_profile"
|
||||||
|
export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id
|
||||||
|
export CLOUDFLARE_GATEWAY_ID=your-gateway-id
|
||||||
|
export CLOUDFLARE_API_TOKEN=your-api-token
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Cloudflare Workers AI
|
### Cloudflare Workers AI
|
||||||
|
|
||||||
Cloudflare Workers AI lets you run AI models on Cloudflare's global network directly via REST API, with no separate provider accounts needed for supported models.
|
Cloudflare Workers AI lets you run AI models on Cloudflare's global network directly via REST API, with no separate provider accounts needed for supported models.
|
||||||
|
|
||||||
1. Head over to the [Cloudflare dashboard](https://dash.cloudflare.com/), navigate to **Workers AI**, and select **Use REST API** to get your Account ID and create an API token.
|
1. Head over to the [Cloudflare dashboard](https://dash.cloudflare.com/), navigate to **Workers AI**, and select **Use REST API** to get your **Account ID** and create an API token.
|
||||||
|
|
||||||
2. Set your Account ID as an environment variable.
|
2. Run the `/connect` command and search for **Cloudflare Workers AI**.
|
||||||
|
|
||||||
```bash title="~/.bash_profile"
|
|
||||||
export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Run the `/connect` command and search for **Cloudflare Workers AI**.
|
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
/connect
|
/connect
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Enter your Cloudflare API token.
|
3. Enter your **Account ID** when prompted.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
┌ Enter your Cloudflare Account ID
|
||||||
|
│
|
||||||
|
│
|
||||||
|
└ enter
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Enter your **Cloudflare API key**.
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
┌ API key
|
┌ API key
|
||||||
@@ -571,18 +587,19 @@ Cloudflare Workers AI lets you run AI models on Cloudflare's global network dire
|
|||||||
└ enter
|
└ enter
|
||||||
```
|
```
|
||||||
|
|
||||||
Or set it as an environment variable.
|
|
||||||
|
|
||||||
```bash title="~/.bash_profile"
|
|
||||||
export CLOUDFLARE_API_KEY=your-api-token
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Run the `/models` command to select a model.
|
5. Run the `/models` command to select a model.
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
/models
|
/models
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Alternatively, you can set environment variables instead of using `/connect`.
|
||||||
|
|
||||||
|
```bash title="~/.bash_profile"
|
||||||
|
export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id
|
||||||
|
export CLOUDFLARE_API_KEY=your-api-token
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Cortecs
|
### Cortecs
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"displayName": "opencode",
|
"displayName": "opencode",
|
||||||
"description": "opencode for VS Code",
|
"description": "opencode for VS Code",
|
||||||
"version": "1.3.15",
|
"version": "1.3.17",
|
||||||
"publisher": "sst-dev",
|
"publisher": "sst-dev",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
Reference in New Issue
Block a user