mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-31 01:37:28 +08:00
Merge remote-tracking branch 'origin/main' into optimize-message-submission-ux-transitions
# Conflicts: # packages/kilo-vscode/webview-ui/src/context/session-queue.ts # packages/kilo-vscode/webview-ui/src/context/session-utils.ts # packages/kilo-vscode/webview-ui/src/context/session.tsx
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix Agent Manager project accordions that stayed on a loading indicator until you clicked into them. Expanded projects other than the active one now receive their worktrees, sessions, and git stats as soon as Agent Manager opens and again after the panel reloads. While a project is still loading, its local row and section headings stay in place and the rows below fade in as skeleton placeholders instead of showing a single centered spinner, so several projects loading at once look consistent.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep provider names out of the compact prompt model selector label while retaining them in the expanded picker.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Use NVIDIA Parakeet TDT 0.6B v3 as the default speech-to-text model while retaining dynamic model discovery and offline fallback support.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-ui": patch
|
||||
---
|
||||
|
||||
Keep chat pinned to the latest streaming output through layout reflows and downward scrolling.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Restore incremental assistant text streaming across Kilo chat surfaces.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep file mention suggestions current and scoped to the active workspace while preserving instant cached results.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Use the full Codex context window for GPT-5.6 models authenticated through ChatGPT OAuth.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Support setting default open or collapsed state for MCP and generic tool blocks in Display settings
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Optimize Agent Manager tab switching and context transition latency
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Optimize large session loading performance and reduce reactive re-render overhead in the webview.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Optimize TaskHeader and timeline bar calculation performance in the webview.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Switch to the code model when starting implementation after a planning session.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Preserve cached reasoning variants when starting new VS Code sessions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Restore live Git change statistics on the Agent Manager diff toggle for local and worktree selections.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Prompt for explicit, one-shot approval before mutating Git commands run outside the sandbox.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix agent mode switch getting stuck on draft/cloud-import sessions: apply the explicitly selected agent instead of reverting to the stale session agent.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show the model provider next to every model in the model selector.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep queued prompts visible while the server confirms and loads their message parts.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show token throughput by default, with a Display setting to hide it when needed.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Open delegated subagent sessions in Agent Manager inspector tabs alongside terminals.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Open `/sessions` on a git worktree with the Worktree history filter selected by default.
|
||||
@@ -129,6 +129,10 @@ export const Info = Schema.Struct({
|
||||
description:
|
||||
"Controls whether code edit and diff blocks are expanded or collapsed by default in the VS Code chat UI",
|
||||
}),
|
||||
mcp_tool_display: Schema.optional(Schema.Literals(["expanded", "collapsed"])).annotate({
|
||||
description:
|
||||
"Controls whether MCP and generic tool blocks are expanded or collapsed by default in the VS Code chat UI",
|
||||
}),
|
||||
hide_prompt_training_models: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Hide Kilo Gateway models that may train on your prompts from model listings",
|
||||
}),
|
||||
|
||||
@@ -57,6 +57,8 @@ exclude = [
|
||||
'^https?://aistudio\.google\.com/api-keys/?$',
|
||||
# Linear intermittently returns 503 to CI link checks.
|
||||
'^https?://linear\.app/?$',
|
||||
# These VS Code Marketplace pages intermittently return 503 to automated link checks.
|
||||
'^https://marketplace\.visualstudio\.com/items\?itemName=(connor4312\.esbuild-problem-matchers|dbaeumer\.vscode-eslint|esbenp\.prettier-vscode|Requesty\.requesty)$',
|
||||
# Consistently times out in CI
|
||||
'^https?://opncd\.ai',
|
||||
'^https?://zod\.dev/v4/changelog',
|
||||
|
||||
@@ -39,7 +39,6 @@ The GitHub App requests the following permissions:
|
||||
- **Review Style** — Strict, Balanced, or Lenient
|
||||
- **Repository Selection** — All repositories or select specific ones
|
||||
- **Focus Areas** — Security, performance, bugs, style, testing, documentation
|
||||
- **Max Review Time** — 5 to 30 minutes
|
||||
- **Use REVIEW.md** — Load repository-specific review guidance, including sub-agent usage, from the base branch
|
||||
4. Click **Save Configuration**
|
||||
|
||||
@@ -93,7 +92,7 @@ The repository list is synced from GitHub and can be refreshed from the configur
|
||||
|
||||
- Check the Code Reviews page for error details on specific reviews
|
||||
- Ensure you have sufficient Kilo Code credits
|
||||
- Very large PRs may time out — try increasing the max review time
|
||||
- Very large PRs may time out — consider splitting the change into smaller PRs
|
||||
|
||||
### The GitHub App is missing permissions
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ Once connected, return here to configure the Review Agent.
|
||||
- **Review Style** — Strict, Balanced, or Lenient
|
||||
- **Repository Selection** — All repositories or select specific ones
|
||||
- **Focus Areas** — Security, performance, bugs, style, testing, documentation
|
||||
- **Max Review Time** — 5 to 30 minutes
|
||||
- **Use REVIEW.md** — Load repository-specific review guidance, including sub-agent usage, from the base branch
|
||||
4. Click **Save Configuration**
|
||||
|
||||
@@ -120,7 +119,7 @@ You need **Maintainer role** on the GitLab project. Both webhook creation and bo
|
||||
|
||||
- Check the Code Reviews page for error details
|
||||
- Ensure you have sufficient Kilo Code credits
|
||||
- Large MRs may time out — increase the max review time setting
|
||||
- Large MRs may time out — consider splitting the change into smaller MRs
|
||||
|
||||
### No projects listed after connecting
|
||||
|
||||
|
||||
@@ -45,8 +45,7 @@ Before enabling Code Reviews:
|
||||
4. Select a **Review Style** — Strict, Balanced, or Lenient.
|
||||
5. Choose which **repositories** should receive automatic reviews.
|
||||
6. Optionally select **Focus Areas** such as security, performance, bugs, style, testing, or documentation.
|
||||
7. Set a **maximum review time** (5–30 minutes).
|
||||
8. Optionally enable **Use REVIEW.md** and add a `REVIEW.md` file at the repository root to shape how the agent reviews your code.
|
||||
7. Optionally enable **Use REVIEW.md** and add a `REVIEW.md` file at the repository root to shape how the agent reviews your code.
|
||||
|
||||
Once configured, the Review Agent runs automatically on PR/MR events. For platform-specific setup, see:
|
||||
|
||||
@@ -154,8 +153,7 @@ When a pull request or merge request is opened or updated:
|
||||
- Summary findings
|
||||
- Suggested fixes
|
||||
- Risk and severity tagging
|
||||
5. Reviews respect the **maximum time limit** you set.
|
||||
6. Only repositories you’ve selected will trigger automatic analysis.
|
||||
5. Only repositories you’ve selected will trigger automatic analysis.
|
||||
|
||||
Reviews are posted directly in your platform (GitHub or GitLab) as if coming from a team reviewer.
|
||||
|
||||
@@ -235,7 +233,7 @@ The Review Agent is ideal for:
|
||||
|
||||
## Limitations and Guidance
|
||||
|
||||
- Reviews can run for **up to 30 minutes** depending on your setting.
|
||||
- Reviews are subject to a fixed time limit — consider splitting very large PRs into smaller ones.
|
||||
- The agent reviews **only the changed files**, not the entire repository.
|
||||
- Some highly dynamic or domain-specific code may require additional context in `REVIEW.md`.
|
||||
- The agent will only run on **selected repositories**.
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b197d749f21df8d3061b93d13281a346b52514bec1b6855b474a03cfaeeb117b
|
||||
size 2500
|
||||
oid sha256:4b2de227517c16c9aa90af0245da92f8e532ad29e21de2142e50121dbebf55fc
|
||||
size 1574
|
||||
|
||||
@@ -360,15 +360,15 @@ function renderable(part: PartType, showReasoningSummaries = true) {
|
||||
return !!PART_MAPPING[part.type]
|
||||
}
|
||||
|
||||
function toolDefaultOpen(tool: string, shell = false, edit = false) {
|
||||
if (tool === "bash") return shell
|
||||
if (tool === "edit" || tool === "write") return edit
|
||||
if (tool === "apply_patch") return edit
|
||||
function toolDefaultOpen(tool: string, shell = false, edit = false, mcp?: boolean) {
|
||||
if (tool === "bash" || tool === "background_process") return shell
|
||||
if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit
|
||||
if (mcp !== undefined && !ToolRegistry.render(tool)) return mcp
|
||||
}
|
||||
|
||||
function partDefaultOpen(part: PartType, shell = false, edit = false) {
|
||||
function partDefaultOpen(part: PartType, shell = false, edit = false, mcp?: boolean) {
|
||||
if (part.type !== "tool") return
|
||||
return toolDefaultOpen(part.tool, shell, edit)
|
||||
return toolDefaultOpen(part.tool, shell, edit, mcp)
|
||||
}
|
||||
|
||||
function PartGrow(props: {
|
||||
@@ -423,6 +423,7 @@ export function AssistantParts(props: {
|
||||
reasoningAutoCollapse?: boolean
|
||||
shellToolDefaultOpen?: boolean
|
||||
editToolDefaultOpen?: boolean
|
||||
mcpToolDefaultOpen?: boolean
|
||||
animate?: boolean
|
||||
}) {
|
||||
const data = useData()
|
||||
@@ -683,6 +684,7 @@ export function AssistantParts(props: {
|
||||
entry().part,
|
||||
props.shellToolDefaultOpen,
|
||||
props.editToolDefaultOpen,
|
||||
props.mcpToolDefaultOpen,
|
||||
)}
|
||||
reasoningAutoCollapse={props.reasoningAutoCollapse}
|
||||
hideDetails={false}
|
||||
|
||||
@@ -203,6 +203,39 @@ describe("createAutoScroll non-scrollable layouts", () => {
|
||||
ctx.dispose()
|
||||
})
|
||||
|
||||
test("continues following streaming growth after a downward wheel at the bottom", () => {
|
||||
const ctx = setup({ working: true })
|
||||
ctx.el.scrollHeight = 1000
|
||||
ctx.el.clientHeight = 200
|
||||
ctx.el.scrollTop = 800
|
||||
|
||||
ctx.el.fire("wheel", new FakeWheelEvent(50, ctx.el) as unknown as Event)
|
||||
ctx.el.scrollHeight = 1048
|
||||
ctx.resize(0)
|
||||
|
||||
expect(ctx.scroll.userScrolled()).toBe(false)
|
||||
expect(ctx.el.scrollTop).toBe(1048)
|
||||
ctx.dispose()
|
||||
})
|
||||
|
||||
test("continues following when streaming reflow emits scroll before resize", () => {
|
||||
const ctx = setup({ working: true })
|
||||
ctx.el.scrollHeight = 1000
|
||||
ctx.el.clientHeight = 200
|
||||
ctx.el.scrollTop = 800
|
||||
|
||||
ctx.el.scrollHeight = 1108
|
||||
ctx.scroll.handleScroll()
|
||||
|
||||
expect(ctx.scroll.userScrolled()).toBe(false)
|
||||
|
||||
ctx.resize(0)
|
||||
|
||||
expect(ctx.scroll.userScrolled()).toBe(false)
|
||||
expect(ctx.el.scrollTop).toBe(1108)
|
||||
ctx.dispose()
|
||||
})
|
||||
|
||||
test("follows when initially short content starts overflowing", () => {
|
||||
const ctx = setup()
|
||||
ctx.resize()
|
||||
@@ -262,4 +295,23 @@ describe("createAutoScroll non-scrollable layouts", () => {
|
||||
expect(ctx.el.scrollTop).toBe(600)
|
||||
ctx.dispose()
|
||||
})
|
||||
|
||||
test("pauses when a native scrollbar drag changes scroll position without input events", () => {
|
||||
const ctx = setup({ working: true })
|
||||
ctx.el.scrollHeight = 1000
|
||||
ctx.el.clientHeight = 200
|
||||
ctx.el.scrollTop = 800
|
||||
ctx.scroll.handleScroll()
|
||||
|
||||
ctx.el.scrollTop = 600
|
||||
ctx.scroll.handleScroll()
|
||||
|
||||
expect(ctx.scroll.userScrolled()).toBe(true)
|
||||
|
||||
ctx.el.scrollHeight = 1050
|
||||
ctx.resize()
|
||||
|
||||
expect(ctx.el.scrollTop).toBe(600)
|
||||
ctx.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||
let settling = false
|
||||
let settleTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let cleanup: (() => void) | undefined
|
||||
let lastTop = 0
|
||||
let lastHeight = 0
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
contentRef: undefined as HTMLElement | undefined,
|
||||
@@ -99,8 +101,11 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||
const handleScroll = () => {
|
||||
if (!scroll) return
|
||||
|
||||
userActivity.consumeScroll()
|
||||
const input = userActivity.consumeScroll()
|
||||
const distance = distanceFromBottom(scroll)
|
||||
const moved = Math.abs(scroll.scrollTop - lastTop) > 1 && Math.abs(scroll.scrollHeight - lastHeight) <= 1
|
||||
lastTop = scroll.scrollTop
|
||||
lastHeight = scroll.scrollHeight
|
||||
|
||||
if (!canScroll(scroll)) return
|
||||
|
||||
@@ -109,6 +114,10 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||
return
|
||||
}
|
||||
|
||||
// Virtualizer and layout remeasurement can emit scroll before the
|
||||
// ResizeObserver restores bottom-follow. Only user input should pause it.
|
||||
if (!store.userScrolled && !input && !userActivity.isRecent() && !moved) return
|
||||
|
||||
stop()
|
||||
}
|
||||
|
||||
@@ -200,6 +209,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||
|
||||
if (!el) return
|
||||
|
||||
lastTop = el.scrollTop
|
||||
lastHeight = el.scrollHeight
|
||||
updateOverflowAnchor(el)
|
||||
cleanup = userActivity.listen(el)
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ export const createUserActivity = (options: UserActivityOptions) => {
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (!isPotentialScrollInput(event)) return
|
||||
if (!scroll || scroll.scrollHeight - scroll.clientHeight <= 1) return
|
||||
mark(event)
|
||||
if (event.deltaY >= 0 || scroll.scrollTop <= 0) return
|
||||
mark(event)
|
||||
options.onWheelUp()
|
||||
}
|
||||
|
||||
|
||||
@@ -1188,7 +1188,7 @@
|
||||
},
|
||||
"kilo-code.new.showTokenThroughput": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"default": true,
|
||||
"description": "Show tokens-per-second (prompt-processing / text-generation) badges on assistant messages and the task header"
|
||||
},
|
||||
"kilo-code.new.showAutoApprovalReason": {
|
||||
|
||||
@@ -379,6 +379,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
private readonly openSessionIds = new Set<string>()
|
||||
private modelUsageSessionIds: Set<string> = new Set()
|
||||
private syncedChildSessions: Set<string> = new Set()
|
||||
private readonly inspectorSessionIds = new Set<string>()
|
||||
private readonly checkpoints = new Map<string, Promise<void>>()
|
||||
private readonly sessionCreations = new Map<string, Promise<{ sid: string; dir: string } | undefined>>()
|
||||
private readonly draftSessions = new Map<string, { sid: string; dir: string; expires: number }>()
|
||||
@@ -1083,6 +1084,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
if (await this.handleModelSelectorExpandedMessage(message)) return
|
||||
this.handleWebviewFocusMessage(message)
|
||||
this.visibleTaskStreams.handle(message)
|
||||
this.handleStreamVisibilityMessage(message)
|
||||
if (this.handleChildSyncMessage(message)) return
|
||||
if (await this.handleMemoryMessage(message)) return
|
||||
if (this.handleLegacyMigrationMessage(message)) return
|
||||
switch (message.type) {
|
||||
@@ -1171,15 +1174,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
// isn't blocked by slow responses for earlier sessions.
|
||||
void this.handleLoadMessages(message.sessionID, {
|
||||
mode: message.mode,
|
||||
focus: message.focus,
|
||||
before: message.before,
|
||||
limit: message.limit,
|
||||
})
|
||||
break
|
||||
case "syncSession":
|
||||
this.handleSyncSession(message.sessionID, message.parentSessionID).catch((e) =>
|
||||
console.error("[Kilo New] handleSyncSession failed:", e),
|
||||
)
|
||||
break
|
||||
case "loadSessions":
|
||||
this.handleLoadSessions().catch((e) => console.error("[Kilo New] handleLoadSessions failed:", e))
|
||||
break
|
||||
@@ -1572,6 +1571,33 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
}
|
||||
|
||||
private handleChildSyncMessage(
|
||||
message: TypedWebviewMessage & { sessionID?: unknown; parentSessionID?: unknown; scope?: unknown },
|
||||
): boolean {
|
||||
if (message.type !== "syncSession" && message.type !== "unsyncSession") return false
|
||||
if (typeof message.sessionID !== "string") return true
|
||||
if (message.type === "syncSession") {
|
||||
if (message.scope === "inspector") this.inspectorSessionIds.add(message.sessionID)
|
||||
const parent = typeof message.parentSessionID === "string" ? message.parentSessionID : undefined
|
||||
this.handleSyncSession(message.sessionID, parent).catch((e) =>
|
||||
console.error("[Kilo New] handleSyncSession failed:", e),
|
||||
)
|
||||
return true
|
||||
}
|
||||
if (message.scope === "inspector") this.inspectorSessionIds.delete(message.sessionID)
|
||||
this.releaseChildSession(message.sessionID)
|
||||
return true
|
||||
}
|
||||
|
||||
private handleStreamVisibilityMessage(
|
||||
message: TypedWebviewMessage & { sessionID?: unknown; visible?: unknown },
|
||||
): void {
|
||||
if (message.type !== "streamSessionVisible" || message.visible !== false || typeof message.sessionID !== "string") {
|
||||
return
|
||||
}
|
||||
this.releaseChildSession(message.sessionID)
|
||||
}
|
||||
|
||||
private handleEditorOpenMessage(message: Parameters<typeof handleEditorAction>[0]): boolean {
|
||||
return handleEditorAction(message, {
|
||||
// An explicit sessionID (e.g. from validateFiles) takes precedence over
|
||||
@@ -1976,14 +2002,22 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
private async handleLoadMessages(
|
||||
sessionID: string,
|
||||
options: { mode?: MessageLoadMode; before?: string; limit?: number; preserveStream?: boolean } = {},
|
||||
options: {
|
||||
mode?: MessageLoadMode
|
||||
focus?: boolean
|
||||
before?: string
|
||||
limit?: number
|
||||
preserveStream?: boolean
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const mode = options.mode ?? "replace"
|
||||
if (mode === "replace" || mode === "focus") {
|
||||
this.stopCurrentSessionProcesses(sessionID)
|
||||
this.trackedSessionIds.add(sessionID)
|
||||
this.focusSession(sessionID)
|
||||
this.contextSessionID = sessionID
|
||||
if (options.focus !== false) {
|
||||
this.stopCurrentSessionProcesses(sessionID)
|
||||
this.focusSession(sessionID)
|
||||
this.contextSessionID = sessionID
|
||||
}
|
||||
}
|
||||
if (!this.client) {
|
||||
this.postMessage({ type: "error", message: "Not connected to CLI backend", sessionID })
|
||||
@@ -2119,6 +2153,25 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
}
|
||||
|
||||
private releaseChildSession(sessionID: string): void {
|
||||
if (
|
||||
this.inspectorSessionIds.has(sessionID) ||
|
||||
this.visibleTaskStreams.has(sessionID) ||
|
||||
this.currentSession?.id === sessionID ||
|
||||
this.openSessionIds.has(sessionID)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!this.syncedChildSessions.delete(sessionID)) return
|
||||
this.trackedSessionIds.delete(sessionID)
|
||||
this.streams.drop(sessionID)
|
||||
this.visibleTaskStreams.delete(sessionID)
|
||||
this.sessionDirectories.delete(sessionID)
|
||||
this.sessionGitDirectories.delete(sessionID)
|
||||
this.sessionGitRecoveries.delete(sessionID)
|
||||
this.connectionService.pruneSession(sessionID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the context object used by the extracted session-refresh helpers.
|
||||
*/
|
||||
@@ -2252,6 +2305,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.streams.drop(sessionID)
|
||||
this.visibleTaskStreams.delete(sessionID)
|
||||
this.syncedChildSessions.delete(sessionID)
|
||||
this.inspectorSessionIds.delete(sessionID)
|
||||
this.sessionDirectories.delete(sessionID)
|
||||
this.sessionGitDirectories.delete(sessionID)
|
||||
this.sessionGitRecoveries.delete(sessionID)
|
||||
@@ -5091,6 +5145,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.trackedSessionIds.clear()
|
||||
this.openSessionIds.clear()
|
||||
this.syncedChildSessions.clear()
|
||||
this.inspectorSessionIds.clear()
|
||||
this.draftSessions.clear()
|
||||
this.sessionDirectories.clear()
|
||||
this.anacondaDesktop.dispose()
|
||||
|
||||
@@ -69,6 +69,7 @@ import { PLATFORM } from "./constants"
|
||||
import { ProjectRegistry } from "./project/registry"
|
||||
import type { ProjectContext } from "./project/context"
|
||||
import { ProjectContexts } from "./project/contexts"
|
||||
import { hydrateExpanded } from "./project/hydrate"
|
||||
import { createMultiVersion, type MultiVersionHost } from "./provider-multi-version"
|
||||
import { handleProjectMessage, type ProjectMessageDeps } from "./project/messages"
|
||||
import { createProjectWiring } from "./project/wiring"
|
||||
@@ -1625,15 +1626,14 @@ export class AgentManagerProvider implements Disposable {
|
||||
multiProject: this.host.multiProject(),
|
||||
projects,
|
||||
})
|
||||
if (this.panel) {
|
||||
for (const project of projects) {
|
||||
if (project.active || !project.expanded || !project.trusted || project.missing) continue
|
||||
if (this.contexts.get(project.id)) continue
|
||||
const ctx = this.contexts.expand(project.id)
|
||||
if (ctx) this.initExpanded(ctx)
|
||||
}
|
||||
}
|
||||
if (this.panel)
|
||||
hydrateExpanded(projects, {
|
||||
expand: (id) => this.contexts.expand(id),
|
||||
push: (ctx) => this.pushState(ctx),
|
||||
init: (ctx) => this.initExpanded(ctx),
|
||||
})
|
||||
this.projectPollers.sync(this.contexts)
|
||||
this.projectPollers.replay()
|
||||
}
|
||||
|
||||
// Worktree file helpers
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Background project hydration for the Agent Manager sidebar.
|
||||
*
|
||||
* An expanded project accordion renders entirely from a pushed
|
||||
* agentManager.state payload, so a project without one stays on loading
|
||||
* placeholders. For background (non-active) projects that payload is produced
|
||||
* asynchronously after the context loads, which means it can be posted before
|
||||
* the webview mounts and is then lost: the panel attaches and pushes the
|
||||
* catalog first, and the webview only asks for the catalog again on mount.
|
||||
*
|
||||
* Re-pushing already loaded contexts on every catalog push closes that gap for
|
||||
* both a freshly opened panel and a reloaded webview, instead of leaving the
|
||||
* accordion on placeholders until the user clicks a row inside it.
|
||||
*/
|
||||
|
||||
import type { ProjectContext } from "./context"
|
||||
import type { ProjectSnapshot } from "./contexts"
|
||||
|
||||
interface Hooks {
|
||||
/** Expand a project without activating it; undefined when not allowed. */
|
||||
expand: (id: string) => ProjectContext | undefined
|
||||
/** Re-post the state of an already loaded context. */
|
||||
push: (ctx: ProjectContext) => void
|
||||
/** Load a cold context and push its state once it is ready. */
|
||||
init: (ctx: ProjectContext) => void
|
||||
}
|
||||
|
||||
/** Ensure every expanded background project has current state in the webview. */
|
||||
export function hydrateExpanded(projects: readonly ProjectSnapshot[], hooks: Hooks): void {
|
||||
for (const project of projects) {
|
||||
if (project.active || !project.expanded || !project.trusted || project.missing) continue
|
||||
const ctx = hooks.expand(project.id)
|
||||
if (!ctx) continue
|
||||
if (ctx.lifecycle === "ready") hooks.push(ctx)
|
||||
else hooks.init(ctx)
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,10 @@ import type { WorktreeStateManager } from "../WorktreeStateManager"
|
||||
|
||||
export interface PollerPair {
|
||||
stats: { setEnabled(enabled: boolean): void; setVisible(visible: boolean): void; stop(): void }
|
||||
pr: { poller: { setEnabled(enabled: boolean): void; setVisible(visible: boolean): void; stop(): void } }
|
||||
pr: {
|
||||
poller: { setEnabled(enabled: boolean): void; setVisible(visible: boolean): void; stop(): void }
|
||||
replay?(): void
|
||||
}
|
||||
}
|
||||
|
||||
export type StatsOutMessage =
|
||||
@@ -82,12 +85,41 @@ function createPollerPair(ctx: ProjectContext, deps: PollerDeps): PollerPair {
|
||||
|
||||
export class ProjectPollers {
|
||||
private readonly pollers = new Map<string, PollerPair>()
|
||||
private readonly cache = new Map<string, { worktrees?: StatsOutMessage; local?: StatsOutMessage }>()
|
||||
|
||||
constructor(
|
||||
private readonly deps: PollerDeps,
|
||||
private readonly create: (ctx: ProjectContext, deps: PollerDeps) => PollerPair = createPollerPair,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Record the latest stats per project on the way to the webview. A poller
|
||||
* only emits when something changed, so a webview that mounts (or reloads)
|
||||
* after an emit would otherwise keep its stats placeholders forever.
|
||||
*/
|
||||
private recording(id: string): PollerDeps {
|
||||
return {
|
||||
...this.deps,
|
||||
post: (msg) => {
|
||||
const entry = this.cache.get(id) ?? {}
|
||||
if (msg.type === "agentManager.worktreeStats") entry.worktrees = msg
|
||||
if (msg.type === "agentManager.localStats") entry.local = msg
|
||||
this.cache.set(id, entry)
|
||||
this.deps.post(msg)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-post the latest background project stats and PR statuses. */
|
||||
replay(): void {
|
||||
for (const [id, pair] of this.pollers) {
|
||||
const entry = this.cache.get(id)
|
||||
if (entry?.worktrees) this.deps.post(entry.worktrees)
|
||||
if (entry?.local) this.deps.post(entry.local)
|
||||
pair.pr.replay?.()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile pollers with the current expanded set: start pollers for
|
||||
* expanded, trusted, non-active projects whose state is initialized, and
|
||||
@@ -101,7 +133,7 @@ export class ProjectPollers {
|
||||
if (!ctx?.peekState()) continue
|
||||
wanted.add(snap.id)
|
||||
if (this.pollers.has(snap.id)) continue
|
||||
const pair = this.create(ctx, this.deps)
|
||||
const pair = this.create(ctx, this.recording(snap.id))
|
||||
pair.stats.setVisible(this.deps.visible())
|
||||
pair.pr.poller.setVisible(this.deps.visible())
|
||||
pair.stats.setEnabled(true)
|
||||
@@ -113,6 +145,7 @@ export class ProjectPollers {
|
||||
pair.stats.stop()
|
||||
pair.pr.poller.stop()
|
||||
this.pollers.delete(id)
|
||||
this.cache.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +162,7 @@ export class ProjectPollers {
|
||||
pair.pr.poller.stop()
|
||||
}
|
||||
this.pollers.clear()
|
||||
this.cache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -817,6 +817,7 @@ interface LoadMessagesIn {
|
||||
type: "loadMessages"
|
||||
sessionID: string
|
||||
mode?: "replace" | "prepend" | "focus"
|
||||
focus?: boolean
|
||||
before?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ export function registerToggleAutoApprove(
|
||||
const { data: pending } = await client.permission.list({ directory: dir }, { throwOnError: true })
|
||||
for (const req of pending) {
|
||||
if (generation !== snapshot) break
|
||||
if (req.metadata?.["sandboxEscalation"] === true) continue
|
||||
await client.permission
|
||||
.reply({ requestID: req.id, directory: dir, reply: "once" }, { throwOnError: true })
|
||||
.catch((err) => {
|
||||
@@ -91,6 +92,7 @@ export function registerToggleAutoApprove(
|
||||
if (!active) return false
|
||||
const client = tryGetClient(connectionService)
|
||||
if (!client) return false
|
||||
if (event.properties.metadata?.["sandboxEscalation"] === true) return false
|
||||
const dir =
|
||||
directory ?? connectionService.getPermissionDirectory(event.properties.id) ?? resolve(event.properties.sessionID)
|
||||
return client.permission
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
import { mergeFileSearchResults } from "./file-search-results"
|
||||
import { mergeFileSearchItems } from "./file-search-items"
|
||||
import { mergeFileSearchItems, type FileSearchItem } from "./file-search-items"
|
||||
|
||||
type Message = {
|
||||
query: string
|
||||
@@ -20,6 +20,35 @@ type Input = {
|
||||
post: (message: unknown) => void
|
||||
}
|
||||
|
||||
async function fetchBackend(client: KiloClient, dir: string, query: string): Promise<[string[], string[]]> {
|
||||
if (!client?.find?.files) return [[], []]
|
||||
const [fileRes, folderRes] = await Promise.allSettled([
|
||||
client.find.files({ query, directory: dir, type: "file", limit: 50 }, { throwOnError: true }),
|
||||
client.find.files({ query, directory: dir, type: "directory", limit: 50 }, { throwOnError: true }),
|
||||
])
|
||||
return [settled(fileRes, "file"), settled(folderRes, "folder")]
|
||||
}
|
||||
|
||||
function assemble(
|
||||
query: string,
|
||||
dir: string,
|
||||
files: string[],
|
||||
folders: string[],
|
||||
open: Set<string>,
|
||||
): { paths: string[]; items: FileSearchItem[] } {
|
||||
const uri = vscode.window.activeTextEditor?.document.uri
|
||||
const rel = uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath) : undefined
|
||||
const active = rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.replaceAll("\\", "/") : undefined
|
||||
const paths = mergeFileSearchResults({ query, backend: files, open, active })
|
||||
const items = mergeFileSearchItems({
|
||||
query,
|
||||
files: paths,
|
||||
folders,
|
||||
open: new Set(active ? [active, ...open] : open),
|
||||
})
|
||||
return { paths, items }
|
||||
}
|
||||
|
||||
export async function handleFileSearch(input: Input): Promise<void> {
|
||||
const client = input.client
|
||||
if (!client) {
|
||||
@@ -29,27 +58,11 @@ export async function handleFileSearch(input: Input): Promise<void> {
|
||||
|
||||
const id = input.message.sessionID ?? input.current ?? input.context
|
||||
const dir = input.dir(id)
|
||||
const open = dir ? await input.open(dir) : new Set<string>()
|
||||
|
||||
const query = input.message.query
|
||||
void Promise.allSettled([
|
||||
client.find.files({ query, directory: dir, type: "file", limit: 50 }, { throwOnError: true }),
|
||||
client.find.files({ query, directory: dir, type: "directory", limit: 50 }, { throwOnError: true }),
|
||||
]).then(([fileRes, folderRes]) => {
|
||||
const files = settled(fileRes, "file")
|
||||
const folders = settled(folderRes, "folder")
|
||||
const uri = vscode.window.activeTextEditor?.document.uri
|
||||
const rel = uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath) : undefined
|
||||
const active = rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.replaceAll("\\", "/") : undefined
|
||||
const result = mergeFileSearchResults({ query, backend: files, open, active })
|
||||
const items = mergeFileSearchItems({
|
||||
query,
|
||||
files: result,
|
||||
folders,
|
||||
open: new Set(active ? [active, ...open] : open),
|
||||
})
|
||||
input.post({ type: "fileSearchResult", paths: result, items, dir, requestId: input.message.requestId })
|
||||
})
|
||||
const [files, folders] = await fetchBackend(client, dir, query)
|
||||
const open = dir ? await input.open(dir) : new Set<string>()
|
||||
const { paths, items } = assemble(query, dir, files, folders, open)
|
||||
input.post({ type: "fileSearchResult", paths, items, dir, requestId: input.message.requestId })
|
||||
}
|
||||
|
||||
function settled(result: PromiseSettledResult<{ data: string[] }>, kind: "file" | "folder"): string[] {
|
||||
|
||||
@@ -218,6 +218,7 @@ const slimmers: Record<string, (state: Record<string, unknown>) => Record<string
|
||||
multiedit: slimMultiedit,
|
||||
write: slimWrite,
|
||||
bash: slimBash,
|
||||
task: slimOutput,
|
||||
}
|
||||
|
||||
/** Strip provider metadata that the webview never reads from reasoning parts. */
|
||||
|
||||
@@ -6,7 +6,7 @@ export function buildThroughputSettingMessage() {
|
||||
const config = vscode.workspace.getConfiguration("kilo-code.new")
|
||||
return {
|
||||
type: "throughputSettingLoaded" as const,
|
||||
visible: config.get<boolean>("showTokenThroughput", false),
|
||||
visible: config.get<boolean>("showTokenThroughput", true),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ export class VisibleTaskStreams {
|
||||
this.refs.delete(id)
|
||||
}
|
||||
|
||||
has(id: string): boolean {
|
||||
return this.refs.has(id)
|
||||
}
|
||||
|
||||
setActive(active: boolean): void {
|
||||
if (this.active === active) return
|
||||
this.active = active
|
||||
|
||||
@@ -6,6 +6,11 @@ export interface SpeechToTextModelDef {
|
||||
}
|
||||
|
||||
const models: SpeechToTextModelDef[] = [
|
||||
{
|
||||
id: "nvidia/parakeet-tdt-0.6b-v3",
|
||||
label: "Parakeet TDT 0.6B v3",
|
||||
provider: "NVIDIA",
|
||||
},
|
||||
{
|
||||
id: "openai/whisper-large-v3-turbo",
|
||||
label: "Whisper Large V3 Turbo",
|
||||
@@ -38,11 +43,6 @@ const models: SpeechToTextModelDef[] = [
|
||||
label: "Chirp 3",
|
||||
provider: "Google",
|
||||
},
|
||||
{
|
||||
id: "nvidia/parakeet-tdt-0.6b-v3",
|
||||
label: "Parakeet TDT 0.6B v3",
|
||||
provider: "NVIDIA",
|
||||
},
|
||||
]
|
||||
|
||||
export const SPEECH_TO_TEXT_MODELS: readonly SpeechToTextModelDef[] = models
|
||||
|
||||
@@ -105,6 +105,9 @@ test.describe("history session accessibility", () => {
|
||||
|
||||
const local = page.getByRole("tab", { name: "Local" })
|
||||
const worktree = page.getByRole("tab", { name: "Worktree" })
|
||||
await expect(worktree).toHaveAttribute("aria-selected", "true")
|
||||
await expect(page.getByRole("tabpanel", { name: "Worktree" })).toBeVisible()
|
||||
|
||||
await local.focus()
|
||||
await page.keyboard.press("End")
|
||||
await expect(worktree).toBeFocused()
|
||||
|
||||
@@ -26,6 +26,8 @@ test("model selector exposes combobox relationships and active option movement",
|
||||
await expect(combobox).toHaveAttribute("aria-controls", await tree.getAttribute("id"))
|
||||
await expect(combobox).toHaveAttribute("aria-activedescendant", await alpha.getAttribute("id"))
|
||||
await expect(combobox).toHaveAccessibleDescription("Choose the model used for code review tasks.")
|
||||
await expect(alpha.locator(".model-selector-item-provider-tag")).toHaveText("Kilo")
|
||||
await expect(bravo.locator(".model-selector-item-provider-tag")).toHaveText("Kilo")
|
||||
await expect(alpha.locator("button")).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Add to favorites: Alpha" })).toBeVisible()
|
||||
await expect(page.locator(".model-selector-group-label").nth(0)).toContainText("Auto Models")
|
||||
@@ -114,7 +116,7 @@ test("provider groups collapse, expand, and skip their model rows", async ({ pag
|
||||
await page.getByRole("button", { name: "Review model: Alpha" }).click()
|
||||
const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" })
|
||||
const kilo = page.getByRole("treeitem", { name: "Kilo", exact: true })
|
||||
const nvidia = page.getByRole("treeitem", { name: "NVIDIA" })
|
||||
const nvidia = page.getByRole("treeitem", { name: "NVIDIA", exact: true })
|
||||
|
||||
await combobox.press("ArrowDown")
|
||||
await combobox.press("ArrowLeft")
|
||||
@@ -202,8 +204,8 @@ test("selected favorite remains selected when its duplicate group is collapsed",
|
||||
test("large catalogs keep the rendered tree bounded and navigate to distant models", async ({ page }) => {
|
||||
await load(page, "shared--model-selector-large-catalog")
|
||||
|
||||
await page.getByRole("button", { name: "Select model: Provider 0 / Model 300" }).click()
|
||||
const combobox = page.getByRole("combobox", { name: "Select model: Provider 0 / Model 300. Search models" })
|
||||
await page.getByRole("button", { name: "Select model: Model 300" }).click()
|
||||
const combobox = page.getByRole("combobox", { name: "Select model: Model 300. Search models" })
|
||||
const tree = page.getByRole("tree", { name: "Select model" })
|
||||
|
||||
// The window mounts before we measure it, yet stays far smaller than the catalog.
|
||||
|
||||
@@ -21,6 +21,7 @@ const CSS_FILES = [
|
||||
]
|
||||
const TSX_FILES = [
|
||||
path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/SubagentPanel.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/UnassignedSessionsSection.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/NewWorktreeDialog.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/ProjectSelect.tsx"),
|
||||
@@ -49,7 +50,10 @@ const TSX_FILES = [
|
||||
path.join(ROOT, "webview-ui/agent-manager/ProjectList.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/ProjectActions.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/SidebarBody.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/Skeleton.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/TabBar.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/ClosableTab.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/InspectorTabStrip.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/ProjectBranchDialog.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/DefaultBaseBranchDialog.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/tab-rendering.tsx"),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
const TAB_BAR = path.resolve(import.meta.dir, "../../webview-ui/agent-manager/TabBar.tsx")
|
||||
|
||||
describe("Agent Manager diff toggle", () => {
|
||||
it("renders live Git stats rather than pull-request stats", () => {
|
||||
const source = fs.readFileSync(TAB_BAR, "utf-8")
|
||||
const start = source.indexOf('title={props.t("agentManager.diff.toggle")}')
|
||||
const end = source.indexOf("</TooltipKeybind>", start)
|
||||
const button = source.slice(start, end)
|
||||
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
expect(button).toContain("<Show when={hasChanges()}>")
|
||||
expect(button).toContain("stats()!.files")
|
||||
expect(button).toContain("stats()!.additions")
|
||||
expect(button).toContain("stats()!.deletions")
|
||||
expect(button).not.toContain("props.prStatus()")
|
||||
expect(button).not.toContain("pr().additions")
|
||||
expect(button).not.toContain("pr().deletions")
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
|
||||
const css = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/agent-manager.css"), "utf8")
|
||||
const app = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/AgentManagerApp.tsx"), "utf8")
|
||||
const subagent = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/SubagentPanel.tsx"), "utf8")
|
||||
const terminal = readFileSync(
|
||||
resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/TerminalTab.tsx"),
|
||||
"utf8",
|
||||
@@ -25,13 +26,28 @@ test("xterm owns the padding used by FitAddon", () => {
|
||||
expect(term).toMatch(/\bpadding\s*:\s*8px\s*;/)
|
||||
})
|
||||
|
||||
test("uses one persisted width for the diff and terminal inspector", () => {
|
||||
test("uses one persisted width for every inspector panel", () => {
|
||||
expect(app).toContain("persisted?.sidePanelWidth")
|
||||
expect(app).toContain("createPanelResize(setPanelWidth")
|
||||
expect(app).toContain("style={{ width: `${panelWidth()}px` }}")
|
||||
expect(subagent).toContain("InspectorTabStrip")
|
||||
expect(app).not.toContain("diffWidth")
|
||||
expect(app).not.toContain("terminalWidth")
|
||||
})
|
||||
|
||||
test("hides keyboard hints only in inspector tabs", () => {
|
||||
const side = readFileSync(
|
||||
resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/SideTerminalPanel.tsx"),
|
||||
"utf8",
|
||||
)
|
||||
|
||||
expect(subagent).toContain("showKeybind={false}")
|
||||
expect(side).toContain("showKeybind={false}")
|
||||
expect(
|
||||
readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/render.tsx"), "utf8"),
|
||||
).not.toContain("showKeybind={false}")
|
||||
})
|
||||
|
||||
test("limits inspector layout updates during resize", () => {
|
||||
const frames: ((time: number) => void)[] = []
|
||||
const widths: number[] = []
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { ProjectContext, type ProjectContextDeps } from "../../src/agent-manager/project/context"
|
||||
import type { ProjectSnapshot } from "../../src/agent-manager/project/contexts"
|
||||
import { hydrateExpanded } from "../../src/agent-manager/project/hydrate"
|
||||
import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
|
||||
|
||||
const deps: ProjectContextDeps = {
|
||||
log: () => {},
|
||||
state: () =>
|
||||
({ getWorktrees: () => [], getSessions: () => [], flush: async () => {} }) as unknown as WorktreeStateManager,
|
||||
}
|
||||
|
||||
function snapshot(id: string, over: Partial<ProjectSnapshot> = {}): ProjectSnapshot {
|
||||
return {
|
||||
id,
|
||||
root: `/repo/${id}`,
|
||||
label: id,
|
||||
pinned: false,
|
||||
active: false,
|
||||
expanded: true,
|
||||
initialized: false,
|
||||
trusted: true,
|
||||
missing: false,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
/** Contexts keyed by id, created cold. */
|
||||
function contexts(ids: string[]) {
|
||||
const map = new Map(ids.map((id) => [id, new ProjectContext(id, `/repo/${id}`, false, deps)]))
|
||||
return {
|
||||
map,
|
||||
hooks: () => {
|
||||
const pushed: string[] = []
|
||||
const inited: string[] = []
|
||||
return {
|
||||
pushed,
|
||||
inited,
|
||||
expand: (id: string) => map.get(id),
|
||||
push: (ctx: ProjectContext) => pushed.push(ctx.id),
|
||||
init: (ctx: ProjectContext) => inited.push(ctx.id),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("hydrateExpanded", () => {
|
||||
it("loads a cold expanded background project", () => {
|
||||
const all = contexts(["a"])
|
||||
const hooks = all.hooks()
|
||||
hydrateExpanded([snapshot("a")], hooks)
|
||||
expect(hooks.inited).toEqual(["a"])
|
||||
expect(hooks.pushed).toEqual([])
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression: a background project loads asynchronously, so its state push can
|
||||
* land before the webview mounts. The catalog push that follows the mount must
|
||||
* re-push it, otherwise the accordion stays on loading placeholders until the
|
||||
* user clicks a row inside the project.
|
||||
*/
|
||||
it("re-pushes state for an already loaded background project", async () => {
|
||||
const all = contexts(["a"])
|
||||
await all.map.get("a")!.ensureReady(async () => ({ ok: true, refsFixed: 0 }))
|
||||
const hooks = all.hooks()
|
||||
hydrateExpanded([snapshot("a")], hooks)
|
||||
expect(hooks.pushed).toEqual(["a"])
|
||||
expect(hooks.inited).toEqual([])
|
||||
})
|
||||
|
||||
it("skips the active project, which the provider pushes itself", async () => {
|
||||
const all = contexts(["a"])
|
||||
await all.map.get("a")!.ensureReady(async () => ({ ok: true, refsFixed: 0 }))
|
||||
const hooks = all.hooks()
|
||||
hydrateExpanded([snapshot("a", { active: true })], hooks)
|
||||
expect(hooks.pushed).toEqual([])
|
||||
expect(hooks.inited).toEqual([])
|
||||
})
|
||||
|
||||
it("skips collapsed, untrusted, and missing projects", () => {
|
||||
const all = contexts(["collapsed", "untrusted", "missing"])
|
||||
const hooks = all.hooks()
|
||||
hydrateExpanded(
|
||||
[
|
||||
snapshot("collapsed", { expanded: false }),
|
||||
snapshot("untrusted", { trusted: false }),
|
||||
snapshot("missing", { missing: true }),
|
||||
],
|
||||
hooks,
|
||||
)
|
||||
expect(hooks.pushed).toEqual([])
|
||||
expect(hooks.inited).toEqual([])
|
||||
})
|
||||
|
||||
it("skips a project the coordinator refuses to expand", () => {
|
||||
const hooks = {
|
||||
pushed: [] as string[],
|
||||
inited: [] as string[],
|
||||
expand: () => undefined,
|
||||
push: () => {},
|
||||
init: () => {},
|
||||
}
|
||||
hydrateExpanded([snapshot("gone")], hooks)
|
||||
expect(hooks.pushed).toEqual([])
|
||||
expect(hooks.inited).toEqual([])
|
||||
})
|
||||
|
||||
it("hydrates every expanded background project, not just the first", async () => {
|
||||
const all = contexts(["a", "b", "c"])
|
||||
await all.map.get("b")!.ensureReady(async () => ({ ok: true, refsFixed: 0 }))
|
||||
const hooks = all.hooks()
|
||||
hydrateExpanded([snapshot("a"), snapshot("b"), snapshot("c")], hooks)
|
||||
expect(hooks.inited).toEqual(["a", "c"])
|
||||
expect(hooks.pushed).toEqual(["b"])
|
||||
})
|
||||
})
|
||||
@@ -173,3 +173,78 @@ describe("ProjectPollers", () => {
|
||||
expect(made.get("prj-extra")!.stopped).toEqual({ stats: true, pr: true })
|
||||
})
|
||||
})
|
||||
|
||||
/** Poller pair that exposes the injected deps so a test can emit like a real poller. */
|
||||
function recorder() {
|
||||
const posted: StatsOutMessage[] = []
|
||||
const emit = new Map<string, (msg: StatsOutMessage) => void>()
|
||||
const replayed: string[] = []
|
||||
const create = (ctx: { id: string }, injected: { post: (msg: StatsOutMessage) => void }): PollerPair => {
|
||||
emit.set(ctx.id, injected.post)
|
||||
return {
|
||||
stats: { setEnabled: () => {}, setVisible: () => {}, stop: () => {} },
|
||||
pr: {
|
||||
poller: { setEnabled: () => {}, setVisible: () => {}, stop: () => {} },
|
||||
replay: () => replayed.push(ctx.id),
|
||||
},
|
||||
}
|
||||
}
|
||||
const deps = {
|
||||
git: {} as GitOps,
|
||||
semaphore: undefined as never,
|
||||
post: (msg: StatsOutMessage) => posted.push(msg),
|
||||
openExternal: () => {},
|
||||
visible: () => true,
|
||||
log: () => {},
|
||||
}
|
||||
return { posted, emit, replayed, create, deps }
|
||||
}
|
||||
|
||||
const LOCAL: StatsOutMessage = {
|
||||
type: "agentManager.localStats",
|
||||
projectId: "prj-extra",
|
||||
stats: { branch: "main", files: 0, additions: 0, deletions: 0, ahead: 0, behind: 0 } as never,
|
||||
}
|
||||
|
||||
describe("ProjectPollers.replay", () => {
|
||||
/**
|
||||
* Regression: a background poller only emits on change, so a webview that
|
||||
* mounts or reloads after the emit would keep its stats placeholders forever.
|
||||
*/
|
||||
it("re-posts the latest stats for a background project", () => {
|
||||
const contexts = setup([stored("prj-extra")])
|
||||
expand(contexts, "prj-extra")
|
||||
const rec = recorder()
|
||||
const pollers = new ProjectPollers(rec.deps as never, rec.create as never)
|
||||
pollers.sync(contexts)
|
||||
rec.emit.get("prj-extra")!(LOCAL)
|
||||
expect(rec.posted).toHaveLength(1)
|
||||
pollers.replay()
|
||||
expect(rec.posted).toHaveLength(2)
|
||||
expect(rec.posted[1]).toBe(LOCAL)
|
||||
expect(rec.replayed).toEqual(["prj-extra"])
|
||||
})
|
||||
|
||||
it("posts nothing for a project that has not emitted yet", () => {
|
||||
const contexts = setup([stored("prj-extra")])
|
||||
expand(contexts, "prj-extra")
|
||||
const rec = recorder()
|
||||
const pollers = new ProjectPollers(rec.deps as never, rec.create as never)
|
||||
pollers.sync(contexts)
|
||||
pollers.replay()
|
||||
expect(rec.posted).toEqual([])
|
||||
})
|
||||
|
||||
it("drops cached stats for a collapsed project", () => {
|
||||
const contexts = setup([stored("prj-extra")])
|
||||
expand(contexts, "prj-extra")
|
||||
const rec = recorder()
|
||||
const pollers = new ProjectPollers(rec.deps as never, rec.create as never)
|
||||
pollers.sync(contexts)
|
||||
rec.emit.get("prj-extra")!(LOCAL)
|
||||
contexts.collapse("prj-extra")
|
||||
pollers.sync(contexts)
|
||||
pollers.replay()
|
||||
expect(rec.posted).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { handleFileSearch } from "../../src/kilo-provider/file-search"
|
||||
|
||||
type Query = { query: string; directory: string; type: "file" | "directory"; limit: number }
|
||||
|
||||
function client(data: { files: string[]; folders: string[] }) {
|
||||
const calls: Query[] = []
|
||||
return {
|
||||
calls,
|
||||
value: {
|
||||
find: {
|
||||
files: async (query: Query) => {
|
||||
calls.push(query)
|
||||
return { data: query.type === "file" ? data.files : data.folders }
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("handleFileSearch", () => {
|
||||
it("posts one fresh response for each request", async () => {
|
||||
const api = client({ files: ["src/a.ts"], folders: ["src"] })
|
||||
const posted: unknown[] = []
|
||||
|
||||
await handleFileSearch({
|
||||
client: api.value as never,
|
||||
message: { query: "", requestId: "request-1", sessionID: "session-1" },
|
||||
dir: (id) => (id === "session-1" ? "/repo" : ""),
|
||||
open: async () => new Set(["src/open.ts"]),
|
||||
post: (message) => posted.push(message),
|
||||
})
|
||||
|
||||
expect(api.calls).toEqual([
|
||||
{ query: "", directory: "/repo", type: "file", limit: 50 },
|
||||
{ query: "", directory: "/repo", type: "directory", limit: 50 },
|
||||
])
|
||||
expect(posted).toHaveLength(1)
|
||||
expect(posted[0]).toEqual({
|
||||
type: "fileSearchResult",
|
||||
requestId: "request-1",
|
||||
dir: "/repo",
|
||||
paths: ["src/open.ts", "src/a.ts"],
|
||||
items: [
|
||||
{ path: "src/open.ts", type: "opened-file" },
|
||||
{ path: "src/a.ts", type: "file" },
|
||||
{ path: "src", type: "folder" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it("returns an empty fresh response when files were deleted", async () => {
|
||||
const api = client({ files: [], folders: [] })
|
||||
const posted: unknown[] = []
|
||||
|
||||
await handleFileSearch({
|
||||
client: api.value as never,
|
||||
message: { query: "", requestId: "request-empty" },
|
||||
dir: () => "/repo",
|
||||
open: async () => new Set(),
|
||||
post: (message) => posted.push(message),
|
||||
})
|
||||
|
||||
expect(posted).toEqual([
|
||||
{
|
||||
type: "fileSearchResult",
|
||||
requestId: "request-empty",
|
||||
dir: "/repo",
|
||||
paths: [],
|
||||
items: [],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -255,69 +255,67 @@ describe("hasByok", () => {
|
||||
|
||||
describe("buildTriggerLabel", () => {
|
||||
it("returns resolved model name for non-kilo provider unchanged", () => {
|
||||
expect(buildTriggerLabel("GPT-4o", "openai", undefined, null, false, "", true, labels)).toBe("GPT-4o")
|
||||
expect(buildTriggerLabel("GPT-4o", "openai", null, false, "", true, labels)).toBe("GPT-4o")
|
||||
})
|
||||
|
||||
it("strips sub-provider prefix from resolved name for kilo gateway models", () => {
|
||||
expect(
|
||||
buildTriggerLabel("Anthropic: Claude Sonnet", KILO_GATEWAY_ID, undefined, null, false, "", true, labels),
|
||||
).toBe("Claude Sonnet")
|
||||
expect(buildTriggerLabel("Anthropic: Claude Sonnet", KILO_GATEWAY_ID, null, false, "", true, labels)).toBe(
|
||||
"Claude Sonnet",
|
||||
)
|
||||
})
|
||||
|
||||
it("does not strip prefix for non-kilo provider even if name contains ': '", () => {
|
||||
expect(buildTriggerLabel("Anthropic: Claude Sonnet", "anthropic", undefined, null, false, "", true, labels)).toBe(
|
||||
expect(buildTriggerLabel("Anthropic: Claude Sonnet", "anthropic", null, false, "", true, labels)).toBe(
|
||||
"Anthropic: Claude Sonnet",
|
||||
)
|
||||
})
|
||||
|
||||
it("returns resolved name as-is when providerID is undefined", () => {
|
||||
expect(buildTriggerLabel("GPT-4o", undefined, undefined, null, false, "", true, labels)).toBe("GPT-4o")
|
||||
expect(buildTriggerLabel("GPT-4o", undefined, null, false, "", true, labels)).toBe("GPT-4o")
|
||||
})
|
||||
|
||||
it("returns providerName / resolvedName for non-kilo provider with providerName", () => {
|
||||
expect(buildTriggerLabel("GPT-4o", "openai", "OpenAI", null, false, "", true, labels)).toBe("OpenAI / GPT-4o")
|
||||
it("does not add provider name to the compact label", () => {
|
||||
expect(buildTriggerLabel("GPT-5.6 Luna", "openai", null, false, "", true, labels)).toBe("GPT-5.6 Luna")
|
||||
})
|
||||
|
||||
it("returns modelID for kilo gateway raw selection", () => {
|
||||
const raw = { providerID: "kilo", modelID: "kilo-auto/frontier" }
|
||||
expect(buildTriggerLabel(undefined, undefined, undefined, raw, false, "", true, labels)).toBe("kilo-auto/frontier")
|
||||
expect(buildTriggerLabel(undefined, undefined, raw, false, "", true, labels)).toBe("kilo-auto/frontier")
|
||||
})
|
||||
|
||||
it("returns providerID / modelID for non-kilo raw selection", () => {
|
||||
const raw = { providerID: "anthropic", modelID: "claude-3-5-sonnet" }
|
||||
expect(buildTriggerLabel(undefined, undefined, undefined, raw, false, "", true, labels)).toBe(
|
||||
"anthropic / claude-3-5-sonnet",
|
||||
)
|
||||
expect(buildTriggerLabel(undefined, undefined, raw, false, "", true, labels)).toBe("anthropic / claude-3-5-sonnet")
|
||||
})
|
||||
|
||||
it("returns clearLabel when allowClear and no selection", () => {
|
||||
expect(buildTriggerLabel(undefined, undefined, undefined, null, true, "None", true, labels)).toBe("None")
|
||||
expect(buildTriggerLabel(undefined, undefined, null, true, "None", true, labels)).toBe("None")
|
||||
})
|
||||
|
||||
it("falls back to labels.notSet when allowClear and clearLabel is empty", () => {
|
||||
expect(buildTriggerLabel(undefined, undefined, undefined, null, true, "", true, labels)).toBe("Not set")
|
||||
expect(buildTriggerLabel(undefined, undefined, null, true, "", true, labels)).toBe("Not set")
|
||||
})
|
||||
|
||||
it("returns labels.select when providers exist and no selection", () => {
|
||||
expect(buildTriggerLabel(undefined, undefined, undefined, null, false, "", true, labels)).toBe("Select model")
|
||||
expect(buildTriggerLabel(undefined, undefined, null, false, "", true, labels)).toBe("Select model")
|
||||
})
|
||||
|
||||
it("returns labels.noProviders when no providers available", () => {
|
||||
expect(buildTriggerLabel(undefined, undefined, undefined, null, false, "", false, labels)).toBe("No providers")
|
||||
expect(buildTriggerLabel(undefined, undefined, null, false, "", false, labels)).toBe("No providers")
|
||||
})
|
||||
|
||||
it("prefers resolvedName over raw selection", () => {
|
||||
const raw = { providerID: "anthropic", modelID: "claude-3-5-sonnet" }
|
||||
expect(buildTriggerLabel("Claude Sonnet", undefined, undefined, raw, false, "", true, labels)).toBe("Claude Sonnet")
|
||||
expect(buildTriggerLabel("Claude Sonnet", undefined, raw, false, "", true, labels)).toBe("Claude Sonnet")
|
||||
})
|
||||
|
||||
it("ignores partial raw selection (only providerID)", () => {
|
||||
const raw = { providerID: "anthropic", modelID: "" }
|
||||
expect(buildTriggerLabel(undefined, undefined, undefined, raw, false, "", true, labels)).toBe("Select model")
|
||||
expect(buildTriggerLabel(undefined, undefined, raw, false, "", true, labels)).toBe("Select model")
|
||||
})
|
||||
|
||||
it("ignores partial raw selection (only modelID)", () => {
|
||||
const raw = { providerID: "", modelID: "claude-3-5-sonnet" }
|
||||
expect(buildTriggerLabel(undefined, undefined, undefined, raw, false, "", true, labels)).toBe("Select model")
|
||||
expect(buildTriggerLabel(undefined, undefined, raw, false, "", true, labels)).toBe("Select model")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -89,6 +89,16 @@ describe("sendCommand dismisses pending tool requests", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("confirmed queued prompts retain optimistic parts", () => {
|
||||
const source = readFile(SESSION_FILE)
|
||||
const body = extractFunctionBody(source, "handleMessageCreated")
|
||||
|
||||
it("does not clear optimistic parts before canonical part events arrive", () => {
|
||||
expect(body).toContain("Keep placeholder parts until their canonical part.updated events arrive")
|
||||
expect(body).not.toContain("delete p[message.id]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("static command completion contract", () => {
|
||||
const source = readFile(SESSION_FILE)
|
||||
|
||||
@@ -613,8 +623,8 @@ describe("Optimistic parts preservation and smooth status contract", () => {
|
||||
|
||||
it("handlePartUpdated replaces matching optimistic parts in place", () => {
|
||||
const updated = extractFunctionBody(source, "handlePartUpdated")
|
||||
expect(updated).toContain("pendingOptimisticParts.get(effectiveMessageID)")
|
||||
expect(updated).toContain("optIds.delete")
|
||||
expect(updated).toContain("optimisticParts.get(effectiveMessageID)")
|
||||
expect(updated).toContain("mergeOptimisticPart")
|
||||
})
|
||||
|
||||
it("statusText derives status only from the active turn after the last user message", () => {
|
||||
|
||||
@@ -146,6 +146,14 @@ describe("resolvePromptAgent", () => {
|
||||
expect(resolvePromptAgent({ selections: {}, pending: "code" })).toBe("code")
|
||||
})
|
||||
|
||||
it("honors an explicit pending selection for a draft scope with no per-session entry", () => {
|
||||
expect(resolvePromptAgent({ sessionID: "draft-1", selections: {}, pending: "ask" })).toBe("ask")
|
||||
})
|
||||
|
||||
it("does not fall back to pending for a real server session with no per-session entry", () => {
|
||||
expect(resolvePromptAgent({ sessionID: "ses_1", selections: {}, pending: "ask" })).toBeUndefined()
|
||||
})
|
||||
|
||||
it("omits the agent when there is no explicit selection", () => {
|
||||
expect(resolvePromptAgent({ sessionID: "ses_1", selections: {}, pending: null })).toBeUndefined()
|
||||
expect(resolvePromptAgent({ selections: {}, pending: null })).toBeUndefined()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { mergeParts, sameParts } from "../../webview-ui/src/context/session-parts"
|
||||
import { mergeOptimisticPart, mergeParts, sameParts } from "../../webview-ui/src/context/session-parts"
|
||||
import type { Part } from "../../webview-ui/src/types/messages"
|
||||
|
||||
function text(id: string, value: string, time: { start?: number; end?: number } = {}): Part {
|
||||
@@ -10,6 +10,10 @@ function tool(id: string): Part {
|
||||
return { id, messageID: "m1", type: "tool", tool: "bash", state: { status: "pending", input: {} } }
|
||||
}
|
||||
|
||||
function file(id: string): Part {
|
||||
return { id, messageID: "m1", type: "file", mime: "text/plain", url: "data:,file" }
|
||||
}
|
||||
|
||||
function value(parts: Part[], id: string) {
|
||||
const part = parts.find((item) => item.id === id)
|
||||
if (!part || part.type !== "text") return
|
||||
@@ -100,6 +104,24 @@ describe("mergeParts", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("mergeOptimisticPart", () => {
|
||||
it("replaces the optimistic user part when its canonical event arrives", () => {
|
||||
const current = [text("client", "queued prompt")]
|
||||
const result = mergeOptimisticPart(current, new Set(["client"]), text("server", "queued prompt"))
|
||||
|
||||
expect(result.parts).toEqual([text("server", "queued prompt")])
|
||||
expect(result.replaced).toBe("client")
|
||||
})
|
||||
|
||||
it("keeps unmatched optimistic parts while canonical attachments arrive", () => {
|
||||
const current = [text("client-text", "queued prompt"), file("client-file")]
|
||||
const result = mergeOptimisticPart(current, new Set(["client-text", "client-file"]), file("server-file"))
|
||||
|
||||
expect(result.parts.map((part) => part.id)).toEqual(["client-text", "server-file"])
|
||||
expect(result.replaced).toBe("client-file")
|
||||
})
|
||||
})
|
||||
|
||||
describe("sameParts", () => {
|
||||
it("accepts equal hydrated and snapshot parts", () => {
|
||||
expect(sameParts([text("p1", "done", { end: 2 })], [text("p1", "done", { end: 2 })])).toBe(true)
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("selectSession keeps the chat in sync with the selection while offline"
|
||||
// Queue a replay unconditionally. The earlier `deferredFetch = ready ? undefined : id`
|
||||
// form skipped cached sessions, so a reconnect never re-sent the focus load that
|
||||
// re-focuses the backend (focusSession/contextSessionID/SSE tracking/reconcile).
|
||||
expect(body).toContain("deferredFetch = id")
|
||||
expect(body).toContain("deferredFetch = { id, focus }")
|
||||
expect(body).not.toMatch(/deferredFetch\s*=\s*ready\s*\?/)
|
||||
})
|
||||
})
|
||||
@@ -61,12 +61,15 @@ describe("a deferred fetch is replayed on reconnect", () => {
|
||||
const effect = source.slice(source.indexOf("on(server.isConnected"))
|
||||
expect(effect).toContain("deferredFetch")
|
||||
// Replays with the focus/replace choice so cached sessions still re-focus the backend.
|
||||
expect(effect).toMatch(/loadFocusedMessages\(\s*id,\s*loaded\(\)\.has\(id\)\s*\)/)
|
||||
expect(effect).toMatch(
|
||||
/loadFocusedMessages\(\s*pending\.id,\s*loaded\(\)\.has\(pending\.id\),\s*pending\.focus\s*\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it("the focused load helper sends focus for cached sessions and replace otherwise", () => {
|
||||
const helper = source.slice(source.indexOf("function loadFocusedMessages("))
|
||||
expect(helper).toMatch(/mode: "focus"/)
|
||||
expect(helper).toMatch(/mode: "replace"/)
|
||||
expect(helper).toContain("focus: false")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,15 +66,24 @@ describe("session variants", () => {
|
||||
|
||||
it("persists an explicit default selection", () => {
|
||||
const state = setup()
|
||||
state.selections["agent/code/anthropic/claude-sonnet-4"] = "high"
|
||||
state.variants.select(undefined)
|
||||
expect(state.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "" })
|
||||
expect(state.variants.current()).toBeUndefined()
|
||||
expect(state.messages).toEqual([{ type: "persistVariant", key: "agent/code/anthropic/claude-sonnet-4", value: "" }])
|
||||
})
|
||||
|
||||
it("carries the model default across model changes", () => {
|
||||
const state = setup()
|
||||
state.variants.carry(model, undefined, "code")
|
||||
expect(state.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "" })
|
||||
it("does not shadow a cached variant when carrying the model default", () => {
|
||||
const global = setup()
|
||||
global.selections["agent/code/anthropic/claude-sonnet-4"] = "high"
|
||||
global.variants.carry(model, undefined, "code")
|
||||
expect(global.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "high" })
|
||||
expect(global.messages).toEqual([])
|
||||
|
||||
const session = setup("session-a")
|
||||
session.selections["agent/code/anthropic/claude-sonnet-4"] = "high"
|
||||
session.variants.carry(model, undefined, "code", "session-a")
|
||||
expect(session.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "high" })
|
||||
expect(session.variants.current()).toBe("high")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,6 +33,6 @@ describe("speech-to-text discovery", () => {
|
||||
it("rejects empty or malformed catalogs so callers can use the static fallback", () => {
|
||||
expect(parseSpeechToTextCatalog([])).toBeUndefined()
|
||||
expect(parseSpeechToTextCatalog({ data: [] })).toBeUndefined()
|
||||
expect(DEFAULT_SPEECH_TO_TEXT_MODEL.id).toBe("openai/whisper-large-v3-turbo")
|
||||
expect(DEFAULT_SPEECH_TO_TEXT_MODEL.id).toBe("nvidia/parakeet-tdt-0.6b-v3")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
} from "../../src/speech-to-text/models"
|
||||
|
||||
describe("speech-to-text model catalog", () => {
|
||||
it("uses Whisper Large V3 Turbo as the fallback default", () => {
|
||||
expect(DEFAULT_SPEECH_TO_TEXT_MODEL.id).toBe("openai/whisper-large-v3-turbo")
|
||||
it("uses NVIDIA Parakeet TDT 0.6B v3 as the fallback default", () => {
|
||||
expect(DEFAULT_SPEECH_TO_TEXT_MODEL.id).toBe("nvidia/parakeet-tdt-0.6b-v3")
|
||||
expect(DEFAULT_SPEECH_TO_TEXT_MODEL.id).toBe(SPEECH_TO_TEXT_MODELS[0]?.id)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { createSubagentTabs } from "../../webview-ui/agent-manager/subagent-tabs"
|
||||
|
||||
function scene() {
|
||||
const [current] = createSignal<string | undefined>("parent")
|
||||
const calls = {
|
||||
synced: [] as Array<[string, string | undefined]>,
|
||||
unsynced: [] as string[],
|
||||
shown: 0,
|
||||
hidden: 0,
|
||||
}
|
||||
const tabs = createSubagentTabs({
|
||||
current,
|
||||
sync: (id, parent) => calls.synced.push([id, parent]),
|
||||
unsync: (id) => calls.unsynced.push(id),
|
||||
show: () => calls.shown++,
|
||||
hide: () => calls.hidden++,
|
||||
})
|
||||
return { tabs, calls }
|
||||
}
|
||||
|
||||
describe("Agent Manager subagent tabs", () => {
|
||||
it("opens multiple child sessions and syncs each to its parent", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
item.tabs.open("child-1", "First", "parent-1")
|
||||
item.tabs.open("child-2", "Second", "parent-2")
|
||||
|
||||
expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["child-1", "child-2"])
|
||||
expect(item.tabs.active()).toBe("child-2")
|
||||
expect(item.calls.synced).toEqual([
|
||||
["child-1", "parent-1"],
|
||||
["child-2", "parent-2"],
|
||||
])
|
||||
expect(item.calls.shown).toBe(2)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("closes the active tab onto its nearest survivor and hides when empty", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
item.tabs.open("one", "One")
|
||||
item.tabs.open("two", "Two")
|
||||
item.tabs.open("three", "Three")
|
||||
|
||||
item.tabs.close("two")
|
||||
expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["one", "three"])
|
||||
expect(item.tabs.active()).toBe("three")
|
||||
|
||||
item.tabs.close("three")
|
||||
item.tabs.close("one")
|
||||
expect(item.tabs.tabs()).toEqual([])
|
||||
expect(item.tabs.active()).toBeUndefined()
|
||||
expect(item.calls.unsynced).toEqual(["two", "three", "one"])
|
||||
expect(item.calls.hidden).toBe(1)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("supports Close Others and preserves the selected child", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
item.tabs.open("one")
|
||||
item.tabs.open("two")
|
||||
item.tabs.open("three")
|
||||
|
||||
item.tabs.closeOthers("one")
|
||||
expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["one"])
|
||||
expect(item.tabs.active()).toBe("one")
|
||||
expect(item.calls.unsynced).toEqual(["two", "three"])
|
||||
expect(item.calls.shown).toBe(4)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it("reorders tabs without changing the active child", () => {
|
||||
createRoot((dispose) => {
|
||||
const item = scene()
|
||||
item.tabs.open("one")
|
||||
item.tabs.open("two")
|
||||
item.tabs.open("three")
|
||||
item.tabs.select("two")
|
||||
|
||||
item.tabs.reorder("three", "one")
|
||||
expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["three", "one", "two"])
|
||||
expect(item.tabs.active()).toBe("two")
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import * as vscode from "vscode"
|
||||
import { buildThroughputSettingMessage } from "../../src/kilo-provider/throughput-settings"
|
||||
|
||||
type Stub = {
|
||||
getConfiguration: (section?: string) => {
|
||||
get: <T>(key: string, fallback?: T) => T | undefined
|
||||
}
|
||||
}
|
||||
|
||||
const original = vscode.workspace.getConfiguration
|
||||
|
||||
function stubConfig(state: Map<string, unknown>) {
|
||||
;(vscode.workspace as unknown as Stub).getConfiguration = (section?: string) => {
|
||||
if (section !== "kilo-code.new") {
|
||||
return { get: <T>(_key: string, fallback?: T) => fallback }
|
||||
}
|
||||
return {
|
||||
get: <T>(key: string, fallback?: T) => (state.has(key) ? (state.get(key) as T) : fallback),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
;(vscode.workspace as unknown as Stub).getConfiguration = original as Stub["getConfiguration"]
|
||||
})
|
||||
|
||||
describe("buildThroughputSettingMessage", () => {
|
||||
let state: Map<string, unknown>
|
||||
|
||||
beforeEach(() => {
|
||||
state = new Map()
|
||||
stubConfig(state)
|
||||
})
|
||||
|
||||
it("shows throughput by default", () => {
|
||||
expect(buildThroughputSettingMessage().visible).toBe(true)
|
||||
})
|
||||
|
||||
it("returns the persisted visibility preference", () => {
|
||||
state.set("showTokenThroughput", false)
|
||||
|
||||
expect(buildThroughputSettingMessage().visible).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -10,15 +10,32 @@ describe("toolDefaultOpen", () => {
|
||||
it.each(["bash", "background_process"])("uses the terminal preference for %s", (name) => {
|
||||
expect(toolDefaultOpen(tool(name), false, true)).toBe(false)
|
||||
expect(toolDefaultOpen(tool(name), true, false)).toBe(true)
|
||||
expect(toolDefaultOpen(tool(name), true, false, false)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(["edit", "write", "apply_patch"])("uses the code edit preference for %s", (name) => {
|
||||
expect(toolDefaultOpen(tool(name), true, false)).toBe(false)
|
||||
expect(toolDefaultOpen(tool(name), false, true)).toBe(true)
|
||||
expect(toolDefaultOpen(tool(name), false, true, false)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
"slack_post_message",
|
||||
"github_create_issue",
|
||||
"discord_search_messages",
|
||||
"list_mcp_resources",
|
||||
"custom_mcp_tool",
|
||||
])("uses the mcp preference for %s", (name) => {
|
||||
expect(toolDefaultOpen(tool(name), false, false, true)).toBe(true)
|
||||
expect(toolDefaultOpen(tool(name), true, true, false)).toBe(false)
|
||||
expect(toolDefaultOpen(tool(name), true, true)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("leaves unrelated parts unchanged", () => {
|
||||
expect(toolDefaultOpen(tool("read"), true, true)).toBeUndefined()
|
||||
expect(toolDefaultOpen({ type: "text" } as Part, true, true)).toBeUndefined()
|
||||
expect(toolDefaultOpen(tool("read"), true, true, true)).toBeUndefined()
|
||||
expect(toolDefaultOpen(tool("glob"), true, true, true)).toBeUndefined()
|
||||
expect(toolDefaultOpen(tool("grep"), true, true, true)).toBeUndefined()
|
||||
expect(toolDefaultOpen(tool("task"), true, true, true)).toBeUndefined()
|
||||
expect(toolDefaultOpen({ type: "text" } as Part, true, true, true)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { useFileMention } from "../../webview-ui/src/hooks/useFileMention"
|
||||
import { FILE_PICKER_RESULT } from "../../webview-ui/src/hooks/file-mention-utils"
|
||||
import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages"
|
||||
@@ -750,4 +750,333 @@ describe("useFileMention", () => {
|
||||
|
||||
dispose.fn?.()
|
||||
})
|
||||
|
||||
it("renders cached files instantly when opening @ with empty query", () => {
|
||||
const posted: WebviewMessage[] = []
|
||||
const handlers = new Set<(message: ExtensionMessage) => void>()
|
||||
const ctx = {
|
||||
postMessage: (message: WebviewMessage) => posted.push(message),
|
||||
onMessage: (handler: (message: ExtensionMessage) => void) => {
|
||||
handlers.add(handler)
|
||||
return () => handlers.delete(handler)
|
||||
},
|
||||
}
|
||||
|
||||
const dispose: { fn?: () => void } = {}
|
||||
const mention = createRoot((root) => {
|
||||
dispose.fn = root
|
||||
return useFileMention(ctx, undefined, () => false)
|
||||
})
|
||||
mention.onInput("@", 1)
|
||||
const refresh = posted.at(-1)
|
||||
expect(refresh?.type).toBe("requestFileSearch")
|
||||
for (const handler of handlers) {
|
||||
handler({
|
||||
type: "fileSearchResult",
|
||||
requestId: refresh?.type === "requestFileSearch" ? refresh.requestId : "",
|
||||
dir: "/repo",
|
||||
paths: ["src/index.ts", "package.json"],
|
||||
items: [
|
||||
{ path: "src/index.ts", type: "opened-file" },
|
||||
{ path: "package.json", type: "file" },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
expect(mention.mentionResults()).toEqual([
|
||||
{ type: "terminal", value: "terminal", label: "Terminal", description: "Active terminal output" },
|
||||
{ type: "past-chats", value: "past-chats", label: "Past chats", description: "Search previous sessions" },
|
||||
{ type: "opened-file", value: "src/index.ts" },
|
||||
{ type: "file", value: "package.json" },
|
||||
FILE_PICKER_RESULT,
|
||||
])
|
||||
|
||||
// Close mention and reopen @ - should still be instant
|
||||
mention.closeMention()
|
||||
expect(mention.mentionResults().length).toBe(0)
|
||||
|
||||
mention.onInput("@", 1)
|
||||
expect(mention.mentionResults()).toEqual([
|
||||
{ type: "terminal", value: "terminal", label: "Terminal", description: "Active terminal output" },
|
||||
{ type: "past-chats", value: "past-chats", label: "Past chats", description: "Search previous sessions" },
|
||||
{ type: "opened-file", value: "src/index.ts" },
|
||||
{ type: "file", value: "package.json" },
|
||||
FILE_PICKER_RESULT,
|
||||
])
|
||||
|
||||
dispose.fn?.()
|
||||
})
|
||||
|
||||
it("replaces deleted cached files when an empty refresh completes", () => {
|
||||
const posted: WebviewMessage[] = []
|
||||
const handlers = new Set<(message: ExtensionMessage) => void>()
|
||||
const ctx = {
|
||||
postMessage: (message: WebviewMessage) => posted.push(message),
|
||||
onMessage: (handler: (message: ExtensionMessage) => void) => {
|
||||
handlers.add(handler)
|
||||
return () => handlers.delete(handler)
|
||||
},
|
||||
}
|
||||
|
||||
const dispose: { fn?: () => void } = {}
|
||||
const mention = createRoot((root) => {
|
||||
dispose.fn = root
|
||||
return useFileMention(ctx, undefined, () => false)
|
||||
})
|
||||
mention.onInput("@", 1)
|
||||
const prewarm = posted.at(-1)
|
||||
expect(prewarm?.type).toBe("requestFileSearch")
|
||||
for (const handler of handlers) {
|
||||
handler({
|
||||
type: "fileSearchResult",
|
||||
requestId: prewarm?.type === "requestFileSearch" ? prewarm.requestId : "",
|
||||
dir: "/repo",
|
||||
paths: ["deleted.ts"],
|
||||
items: [{ path: "deleted.ts", type: "file" }],
|
||||
})
|
||||
}
|
||||
|
||||
expect(mention.mentionResults()).toContainEqual({ type: "file", value: "deleted.ts" })
|
||||
mention.closeMention()
|
||||
mention.onInput("@", 1)
|
||||
expect(mention.mentionResults()).toContainEqual({ type: "file", value: "deleted.ts" })
|
||||
const refresh = posted.at(-1)
|
||||
expect(refresh?.type).toBe("requestFileSearch")
|
||||
for (const handler of handlers) {
|
||||
handler({
|
||||
type: "fileSearchResult",
|
||||
requestId: refresh?.type === "requestFileSearch" ? refresh.requestId : "",
|
||||
dir: "/repo",
|
||||
paths: [],
|
||||
items: [],
|
||||
})
|
||||
}
|
||||
|
||||
expect(mention.mentionResults()).not.toContainEqual({ type: "file", value: "deleted.ts" })
|
||||
mention.closeMention()
|
||||
mention.onInput("@", 1)
|
||||
expect(mention.mentionResults()).not.toContainEqual({ type: "file", value: "deleted.ts" })
|
||||
|
||||
dispose.fn?.()
|
||||
})
|
||||
|
||||
it("does not reuse cached files after switching sessions", () => {
|
||||
const posted: WebviewMessage[] = []
|
||||
const handlers = new Set<(message: ExtensionMessage) => void>()
|
||||
const ctx = {
|
||||
postMessage: (message: WebviewMessage) => posted.push(message),
|
||||
onMessage: (handler: (message: ExtensionMessage) => void) => {
|
||||
handlers.add(handler)
|
||||
return () => handlers.delete(handler)
|
||||
},
|
||||
}
|
||||
|
||||
const dispose: { fn?: () => void } = {}
|
||||
const state = createRoot((root) => {
|
||||
dispose.fn = root
|
||||
const [session, setSession] = createSignal("session-a")
|
||||
return { mention: useFileMention(ctx, session, () => false), setSession }
|
||||
})
|
||||
state.mention.onInput("@", 1)
|
||||
const first = posted.at(-1)
|
||||
expect(first).toMatchObject({ type: "requestFileSearch", sessionID: "session-a" })
|
||||
for (const handler of handlers) {
|
||||
handler({
|
||||
type: "fileSearchResult",
|
||||
requestId: first?.type === "requestFileSearch" ? first.requestId : "",
|
||||
dir: "/repo-a",
|
||||
paths: ["only-a.ts"],
|
||||
items: [{ path: "only-a.ts", type: "file" }],
|
||||
})
|
||||
}
|
||||
|
||||
state.mention.closeMention()
|
||||
state.setSession("session-b")
|
||||
state.mention.onInput("@", 1)
|
||||
|
||||
expect(state.mention.mentionResults()).not.toContainEqual({ type: "file", value: "only-a.ts" })
|
||||
expect(posted.at(-1)).toMatchObject({ type: "requestFileSearch", sessionID: "session-b", query: "" })
|
||||
|
||||
dispose.fn?.()
|
||||
})
|
||||
|
||||
it("bounds remembered session directories", () => {
|
||||
const posted: WebviewMessage[] = []
|
||||
const handlers = new Set<(message: ExtensionMessage) => void>()
|
||||
const ctx = {
|
||||
postMessage: (message: WebviewMessage) => posted.push(message),
|
||||
onMessage: (handler: (message: ExtensionMessage) => void) => {
|
||||
handlers.add(handler)
|
||||
return () => handlers.delete(handler)
|
||||
},
|
||||
}
|
||||
|
||||
const dispose: { fn?: () => void } = {}
|
||||
const state = createRoot((root) => {
|
||||
dispose.fn = root
|
||||
const [session, setSession] = createSignal("session-0")
|
||||
return { mention: useFileMention(ctx, session, () => false), setSession }
|
||||
})
|
||||
|
||||
const reply = (request: WebviewMessage | undefined, dir: string) => {
|
||||
for (const handler of handlers) {
|
||||
handler({
|
||||
type: "fileSearchResult",
|
||||
requestId: request?.type === "requestFileSearch" ? request.requestId : "",
|
||||
dir,
|
||||
paths: [],
|
||||
items: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
state.mention.onInput("@", 1)
|
||||
reply(posted.at(-1), "/repo/0")
|
||||
for (let index = 1; index <= 8; index++) {
|
||||
state.mention.closeMention()
|
||||
state.setSession(`session-${index}`)
|
||||
state.mention.onInput("@", 1)
|
||||
reply(posted.at(-1), `/repo/${index}`)
|
||||
}
|
||||
|
||||
state.mention.closeMention()
|
||||
state.setSession("session-0")
|
||||
state.mention.onInput("@", 1)
|
||||
|
||||
expect(state.mention.mentionResults()).toEqual([
|
||||
{ type: "terminal", value: "terminal", label: "Terminal", description: "Active terminal output" },
|
||||
{ type: "past-chats", value: "past-chats", label: "Past chats", description: "Search previous sessions" },
|
||||
FILE_PICKER_RESULT,
|
||||
])
|
||||
|
||||
dispose.fn?.()
|
||||
})
|
||||
|
||||
it("preserves the highlighted file when fresh results replace cached results", () => {
|
||||
const posted: WebviewMessage[] = []
|
||||
const handlers = new Set<(message: ExtensionMessage) => void>()
|
||||
const ctx = {
|
||||
postMessage: (message: WebviewMessage) => posted.push(message),
|
||||
onMessage: (handler: (message: ExtensionMessage) => void) => {
|
||||
handlers.add(handler)
|
||||
return () => handlers.delete(handler)
|
||||
},
|
||||
}
|
||||
|
||||
const dispose: { fn?: () => void } = {}
|
||||
const mention = createRoot((root) => {
|
||||
dispose.fn = root
|
||||
return useFileMention(ctx, undefined, () => false)
|
||||
})
|
||||
mention.onInput("@", 1)
|
||||
const prewarm = posted.at(-1)
|
||||
for (const handler of handlers) {
|
||||
handler({
|
||||
type: "fileSearchResult",
|
||||
requestId: prewarm?.type === "requestFileSearch" ? prewarm.requestId : "",
|
||||
dir: "/repo",
|
||||
paths: ["a.ts", "b.ts"],
|
||||
items: [
|
||||
{ path: "a.ts", type: "file" },
|
||||
{ path: "b.ts", type: "file" },
|
||||
],
|
||||
})
|
||||
}
|
||||
mention.closeMention()
|
||||
mention.onInput("@", 1)
|
||||
const selected = mention.mentionResults().findIndex((item) => item.type === "file" && item.value === "b.ts")
|
||||
mention.setMentionIndex(selected)
|
||||
|
||||
const refresh = posted.at(-1)
|
||||
for (const handler of handlers) {
|
||||
handler({
|
||||
type: "fileSearchResult",
|
||||
requestId: refresh?.type === "requestFileSearch" ? refresh.requestId : "",
|
||||
dir: "/repo",
|
||||
paths: ["new.ts", "b.ts"],
|
||||
items: [
|
||||
{ path: "new.ts", type: "file" },
|
||||
{ path: "b.ts", type: "file" },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
expect(mention.mentionResults()[mention.mentionIndex()]).toEqual({ type: "file", value: "b.ts" })
|
||||
|
||||
dispose.fn?.()
|
||||
})
|
||||
|
||||
it("ignores a response after the query changes", async () => {
|
||||
const posted: WebviewMessage[] = []
|
||||
const handlers = new Set<(message: ExtensionMessage) => void>()
|
||||
const ctx = {
|
||||
postMessage: (message: WebviewMessage) => posted.push(message),
|
||||
onMessage: (handler: (message: ExtensionMessage) => void) => {
|
||||
handlers.add(handler)
|
||||
return () => handlers.delete(handler)
|
||||
},
|
||||
}
|
||||
|
||||
const dispose: { fn?: () => void } = {}
|
||||
const mention = createRoot((root) => {
|
||||
dispose.fn = root
|
||||
return useFileMention(ctx, undefined, () => false)
|
||||
})
|
||||
|
||||
mention.onInput("@old", 4)
|
||||
await wait(170)
|
||||
const old = posted.at(-1)
|
||||
expect(old).toMatchObject({ type: "requestFileSearch", query: "old" })
|
||||
mention.onInput("@new", 4)
|
||||
|
||||
for (const handler of handlers) {
|
||||
handler({
|
||||
type: "fileSearchResult",
|
||||
requestId: old?.type === "requestFileSearch" ? old.requestId : "",
|
||||
dir: "/repo",
|
||||
paths: ["old.ts"],
|
||||
items: [{ path: "old.ts", type: "file" }],
|
||||
})
|
||||
}
|
||||
|
||||
expect(mention.mentionResults()).not.toContainEqual({ type: "file", value: "old.ts" })
|
||||
|
||||
dispose.fn?.()
|
||||
})
|
||||
|
||||
it("ignores a response after closing the mention menu", () => {
|
||||
const posted: WebviewMessage[] = []
|
||||
const handlers = new Set<(message: ExtensionMessage) => void>()
|
||||
const ctx = {
|
||||
postMessage: (message: WebviewMessage) => posted.push(message),
|
||||
onMessage: (handler: (message: ExtensionMessage) => void) => {
|
||||
handlers.add(handler)
|
||||
return () => handlers.delete(handler)
|
||||
},
|
||||
}
|
||||
|
||||
const dispose: { fn?: () => void } = {}
|
||||
const mention = createRoot((root) => {
|
||||
dispose.fn = root
|
||||
return useFileMention(ctx, undefined, () => false)
|
||||
})
|
||||
|
||||
mention.onInput("@", 1)
|
||||
const request = posted.at(-1)
|
||||
mention.closeMention()
|
||||
for (const handler of handlers) {
|
||||
handler({
|
||||
type: "fileSearchResult",
|
||||
requestId: request?.type === "requestFileSearch" ? request.requestId : "",
|
||||
dir: "/repo",
|
||||
paths: ["late.ts"],
|
||||
items: [{ path: "late.ts", type: "file" }],
|
||||
})
|
||||
}
|
||||
mention.onInput("@", 1)
|
||||
|
||||
expect(mention.mentionResults()).not.toContainEqual({ type: "file", value: "late.ts" })
|
||||
|
||||
dispose.fn?.()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
|
||||
import {
|
||||
batch,
|
||||
For,
|
||||
Show,
|
||||
createSignal,
|
||||
@@ -177,6 +178,8 @@ import { initialMessage, seedInitialVariant } from "./initial-message"
|
||||
import { SidebarToggleButton } from "./SidebarToggleButton"
|
||||
import { setTabWidths } from "./tab-widths"
|
||||
import { clampPanelWidth, createPanelResize, maxPanelWidth, minPanelWidth, SidePanel } from "./side-panel-layout"
|
||||
import { SubagentPanel } from "./SubagentPanel"
|
||||
import { createSubagentTabs } from "./subagent-tabs"
|
||||
import { buildShortcutCategories } from "./shortcuts"
|
||||
import { tracker } from "./telemetry"
|
||||
import { createChatFocus, createPromptFocus, hasQuestionOption } from "./focus"
|
||||
@@ -305,8 +308,8 @@ const AgentManagerContent: Component = () => {
|
||||
const diffLoading = diffs.diffLoading
|
||||
const setDiffLoading = diffs.setDiffLoading
|
||||
const diffNotices = diffs.diffNotices
|
||||
// Diff and terminal views share one inspector width, restored from webview
|
||||
// state so the user's divider position survives panel reloads.
|
||||
// Diff, PR, terminal, and subagent views share one inspector width, restored
|
||||
// from webview state so the user's divider position survives panel reloads.
|
||||
const [panelWidth, setPanelWidth] = createSignal(clampPanelWidth(persisted?.sidePanelWidth, window.innerWidth))
|
||||
const resizeSide = createPanelResize(setPanelWidth, () => window.innerWidth)
|
||||
const showSideTerminal = () => {
|
||||
@@ -320,6 +323,17 @@ const AgentManagerContent: Component = () => {
|
||||
const reviewComposer = createReviewComposer()
|
||||
const [reviewActive, setReviewActive] = createSignal(false)
|
||||
const [reviewDiffStyle, setReviewDiffStyle] = createSignal<"unified" | "split">("unified")
|
||||
const subagents = createSubagentTabs({
|
||||
current: session.currentSessionID,
|
||||
sync: (id, parentID) => session.syncSession(id, parentID, "inspector"),
|
||||
unsync: (id) => session.unsyncSession(id, "inspector"),
|
||||
show: () => {
|
||||
setHistory(false)
|
||||
setReviewActive(false)
|
||||
setSidePanel(SidePanel.Subagents)
|
||||
},
|
||||
hide: () => setSidePanel(null),
|
||||
})
|
||||
const markdown = createMarkdownRender(vscode)
|
||||
// Per-worktree git stats (diff additions/deletions, commits missing from origin)
|
||||
const worktreeStats = () => registry.active().worktreeStats()
|
||||
@@ -1213,7 +1227,17 @@ const AgentManagerContent: Component = () => {
|
||||
if (match) projectNav.jump(parseInt(match[1]!) - 1)
|
||||
}
|
||||
}
|
||||
const subagent = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ sessionID?: unknown; title?: unknown; parentSessionID?: unknown }>).detail
|
||||
if (typeof detail?.sessionID !== "string") return
|
||||
subagents.open(
|
||||
detail.sessionID,
|
||||
typeof detail.title === "string" ? detail.title : undefined,
|
||||
typeof detail.parentSessionID === "string" ? detail.parentSessionID : undefined,
|
||||
)
|
||||
}
|
||||
window.addEventListener("message", handler)
|
||||
window.addEventListener("agentManager.openSubagent", subagent)
|
||||
// Prevent Cmd/Ctrl shortcuts from triggering native browser actions
|
||||
const preventDefaults = (e: KeyboardEvent) => {
|
||||
if (!(e.metaKey || e.ctrlKey)) return
|
||||
@@ -1262,6 +1286,7 @@ const AgentManagerContent: Component = () => {
|
||||
confirmDeleteWorktree(sel)
|
||||
}
|
||||
window.addEventListener("keydown", deleteKeyHandler)
|
||||
onCleanup(() => window.removeEventListener("agentManager.openSubagent", subagent))
|
||||
|
||||
// Reveal the ⌘/Ctrl+1-9 jump badges on all sidebar items while the modifier is held.
|
||||
// Capture phase so the terminal's key handlers can't swallow them; blur resets state
|
||||
@@ -1990,14 +2015,16 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
|
||||
const selectSessionTab = (id: string, pending: boolean) => {
|
||||
setReviewActive(false)
|
||||
if (pending) {
|
||||
setActivePendingId(id)
|
||||
session.clearCurrentSession()
|
||||
} else {
|
||||
setActivePendingId(undefined)
|
||||
session.selectSession(id)
|
||||
}
|
||||
batch(() => {
|
||||
setReviewActive(false)
|
||||
if (pending) {
|
||||
setActivePendingId(id)
|
||||
session.clearCurrentSession()
|
||||
} else {
|
||||
setActivePendingId(undefined)
|
||||
session.selectSession(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
const termHandlers = createTerminalHandlers({
|
||||
state: terms,
|
||||
@@ -2154,6 +2181,10 @@ const AgentManagerContent: Component = () => {
|
||||
// Close the currently active tab via keyboard shortcut.
|
||||
// If no tabs remain, fall through to close the selected worktree.
|
||||
const closeActiveTab = () => {
|
||||
if (sidePanel() === SidePanel.Subagents && subagents.active()) {
|
||||
subagents.close(subagents.active()!)
|
||||
return
|
||||
}
|
||||
// A focused side terminal owns Cmd+W while its panel is visible.
|
||||
// Closing a chat tab out from under the user's cursor would be surprising.
|
||||
if (sidePanel() === SidePanel.Terminal && terms.sideFocusedId()) {
|
||||
@@ -2512,81 +2543,79 @@ const AgentManagerContent: Component = () => {
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!contextEmpty()}>
|
||||
<div class="am-chat-wrapper">
|
||||
<ChatView
|
||||
onSelectSession={(id) => {
|
||||
if (addSessionToCurrentWorktree(id)) return
|
||||
if (localSessionIDs().includes(id)) {
|
||||
<div class="am-chat-wrapper" classList={{ "am-chat-wrapper-hidden": contextEmpty() }}>
|
||||
<ChatView
|
||||
onSelectSession={(id) => {
|
||||
if (addSessionToCurrentWorktree(id)) return
|
||||
if (localSessionIDs().includes(id)) {
|
||||
session.selectSession(id)
|
||||
if (selection() === null) setSelection(LOCAL)
|
||||
requestChatFocus()
|
||||
return
|
||||
}
|
||||
// Navigate to owning worktree instead of forcing into local mode
|
||||
if (worktreeSessionIds().has(id)) {
|
||||
const ms = managedSessions().find((s) => s.id === id)
|
||||
if (ms?.worktreeId) {
|
||||
selectWorktree(ms.worktreeId)
|
||||
session.selectSession(id)
|
||||
if (selection() === null) setSelection(LOCAL)
|
||||
setReviewActive(false)
|
||||
requestChatFocus()
|
||||
return
|
||||
}
|
||||
// Navigate to owning worktree instead of forcing into local mode
|
||||
if (worktreeSessionIds().has(id)) {
|
||||
const ms = managedSessions().find((s) => s.id === id)
|
||||
if (ms?.worktreeId) {
|
||||
selectWorktree(ms.worktreeId)
|
||||
session.selectSession(id)
|
||||
setReviewActive(false)
|
||||
requestChatFocus()
|
||||
return
|
||||
}
|
||||
}
|
||||
openLocally(id)
|
||||
}}
|
||||
onShowHistory={() => setHistory(true)}
|
||||
onForkMessage={readOnly() ? undefined : handleForkSession}
|
||||
onForkSession={readOnly() ? undefined : handleForkSession}
|
||||
readonly={readOnly()}
|
||||
continueInWorktree={selection() === LOCAL}
|
||||
promptBoxId={`agent-manager:${selection() ?? "unassigned"}`}
|
||||
deferFocusToQuestion={hasQuestionOption}
|
||||
pendingSessionID={selection() === LOCAL ? activePendingId() : undefined}
|
||||
focusOnDraftChange={focusOnDraftChange}
|
||||
onFocusChange={rememberPromptFocus}
|
||||
/>
|
||||
<Show when={readOnly()}>
|
||||
<div class="am-readonly-banner">
|
||||
<Icon name="branch" size="small" />
|
||||
<span class="am-readonly-text">{t("agentManager.session.readonly")}</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
if (!loaded()) return
|
||||
const sid = session.currentSessionID()
|
||||
if (!sid) return
|
||||
metrics.track("open_session_locally", "readonly_banner")
|
||||
openLocally(sid)
|
||||
}}
|
||||
>
|
||||
{t("agentManager.session.openLocally")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
if (!loaded()) return
|
||||
const sid = session.currentSessionID()
|
||||
if (!sid) return
|
||||
metrics.track("promote_session", "readonly_banner")
|
||||
vscode.postMessage({ type: "agentManager.promoteSession", sessionId: sid })
|
||||
}}
|
||||
>
|
||||
{t("agentManager.session.openInWorktree")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
openLocally(id)
|
||||
}}
|
||||
onShowHistory={() => setHistory(true)}
|
||||
onForkMessage={readOnly() ? undefined : handleForkSession}
|
||||
onForkSession={readOnly() ? undefined : handleForkSession}
|
||||
readonly={readOnly()}
|
||||
continueInWorktree={selection() === LOCAL}
|
||||
promptBoxId={`agent-manager:${selection() ?? "unassigned"}`}
|
||||
deferFocusToQuestion={hasQuestionOption}
|
||||
pendingSessionID={selection() === LOCAL ? activePendingId() : undefined}
|
||||
focusOnDraftChange={focusOnDraftChange}
|
||||
onFocusChange={rememberPromptFocus}
|
||||
/>
|
||||
<Show when={readOnly()}>
|
||||
<div class="am-readonly-banner">
|
||||
<Icon name="branch" size="small" />
|
||||
<span class="am-readonly-text">{t("agentManager.session.readonly")}</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
if (!loaded()) return
|
||||
const sid = session.currentSessionID()
|
||||
if (!sid) return
|
||||
metrics.track("open_session_locally", "readonly_banner")
|
||||
openLocally(sid)
|
||||
}}
|
||||
>
|
||||
{t("agentManager.session.openLocally")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
if (!loaded()) return
|
||||
const sid = session.currentSessionID()
|
||||
if (!sid) return
|
||||
metrics.track("promote_session", "readonly_banner")
|
||||
vscode.postMessage({ type: "agentManager.promoteSession", sessionId: sid })
|
||||
}}
|
||||
>
|
||||
{t("agentManager.session.openInWorktree")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
{/* One inspector host for all right-side modes. It stays
|
||||
mounted while a side terminal is alive — hidden via
|
||||
.am-side-host-hidden (absolute + opacity), never
|
||||
unmounted, so xterm render loops keep streaming. */}
|
||||
<Show when={sidePanel() !== null || terms.sides().length > 0}>
|
||||
<Show when={sidePanel() !== null || terms.sides().length > 0 || subagents.tabs().length > 0}>
|
||||
<div
|
||||
class={`am-diff-resize ${sidePanel() === null ? "am-side-host-hidden" : ""}`}
|
||||
style={{ width: `${panelWidth()}px` }}
|
||||
@@ -2653,6 +2682,20 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={subagents.tabs().length > 0}>
|
||||
<SubagentPanel
|
||||
tabs={subagents.tabs}
|
||||
active={subagents.active}
|
||||
visible={() => sidePanel() === SidePanel.Subagents}
|
||||
nextKeybind={kb().nextTab ?? ""}
|
||||
closeKeybind={kb().closeTab ?? ""}
|
||||
onSelect={subagents.select}
|
||||
onClose={subagents.close}
|
||||
onCloseOthers={subagents.closeOthers}
|
||||
onReorder={subagents.reorder}
|
||||
onClosePanel={() => setSidePanel(null)}
|
||||
/>
|
||||
</Show>
|
||||
<SideTerminalPanel
|
||||
state={terms}
|
||||
contextKey={terms.sideKey}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { IconProps } from "@kilocode/kilo-ui/icon"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
|
||||
import { Show, type Component, type JSX } from "solid-js"
|
||||
import { SessionTabMenu } from "../src/components/chat/SessionTabMenu"
|
||||
import { SortableTabContainer } from "../src/components/chat/TabDnd"
|
||||
import { useLanguage } from "../src/context/language"
|
||||
import { parseBindingTokens } from "./keybind-tokens"
|
||||
|
||||
type TabIcon = IconProps["name"] | "spinner"
|
||||
type Value<T> = T | (() => T)
|
||||
|
||||
function value<T>(input: Value<T>): T {
|
||||
return typeof input === "function" ? (input as () => T)() : input
|
||||
}
|
||||
|
||||
export interface ClosableTabProps {
|
||||
id?: string
|
||||
label: Value<string>
|
||||
tooltip: Value<string>
|
||||
icon: Value<TabIcon>
|
||||
iconStatus?: Value<"success" | "failure" | undefined>
|
||||
class?: string
|
||||
focused?: boolean
|
||||
active: boolean
|
||||
closeable?: boolean
|
||||
showKeybind?: boolean
|
||||
keybind?: string
|
||||
closeKeybind?: string
|
||||
role?: "tab"
|
||||
selected?: boolean
|
||||
tabIndex?: number
|
||||
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
|
||||
onSelect: () => void
|
||||
onMiddleClick?: (event: MouseEvent) => void
|
||||
onClose: () => void
|
||||
trailing?: JSX.Element
|
||||
}
|
||||
|
||||
export const ClosableTabChrome: Component<ClosableTabProps> = (props) => {
|
||||
const { t } = useLanguage()
|
||||
const label = () => value(props.label)
|
||||
const tooltip = () => value(props.tooltip)
|
||||
const icon = () => value(props.icon)
|
||||
const status = () => (props.iconStatus ? value(props.iconStatus) : undefined)
|
||||
const keybind = () => (props.showKeybind === false ? "" : (props.keybind ?? ""))
|
||||
const closeKeybind = () => (props.showKeybind === false ? "" : (props.closeKeybind ?? ""))
|
||||
return (
|
||||
<div
|
||||
class={`am-tab am-tab-closable ${props.active ? "am-tab-active" : ""} ${props.focused ? "am-tab-focused" : ""} ${props.class ?? ""}`}
|
||||
>
|
||||
<div
|
||||
class="am-tab-target"
|
||||
role={props.role}
|
||||
aria-selected={props.selected}
|
||||
aria-label={tooltip()}
|
||||
tabIndex={props.tabIndex}
|
||||
onClick={props.onSelect}
|
||||
onMouseDown={props.onMiddleClick}
|
||||
onKeyDown={props.onKeyDown}
|
||||
>
|
||||
<TooltipKeybind
|
||||
title={tooltip()}
|
||||
keybind={keybind()}
|
||||
placement="bottom"
|
||||
gutter={8}
|
||||
class="am-tab-tooltip"
|
||||
openDelay={0}
|
||||
>
|
||||
<span class="am-tab-title">
|
||||
<span class="am-tab-icon" data-run-status={status()}>
|
||||
<Show when={icon() === "spinner"} fallback={<Icon name={icon() as IconProps["name"]} size="small" />}>
|
||||
<Spinner class="am-tab-spinner" />
|
||||
</Show>
|
||||
</span>
|
||||
<span class="am-tab-label">{label()}</span>
|
||||
</span>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
{props.trailing}
|
||||
<Show when={props.closeable !== false}>
|
||||
<TooltipKeybind
|
||||
title={t("agentManager.tab.close")}
|
||||
keybind={closeKeybind()}
|
||||
placement="top"
|
||||
gutter={8}
|
||||
class="am-tab-close-wrap"
|
||||
openDelay={0}
|
||||
>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={t("agentManager.tab.closeTab")}
|
||||
tabIndex={props.active ? 0 : -1}
|
||||
class="am-tab-close"
|
||||
data-tab-close="true"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SortableClosableTab: Component<
|
||||
ClosableTabProps & {
|
||||
id: string
|
||||
onCloseOthers: () => void
|
||||
}
|
||||
> = (props) => (
|
||||
<SortableTabContainer id={props.id}>
|
||||
<SessionTabMenu
|
||||
onClose={props.onClose}
|
||||
onCloseOthers={props.onCloseOthers}
|
||||
closeable={props.closeable}
|
||||
closeShortcut={
|
||||
props.closeKeybind ? (
|
||||
<span class="am-menu-shortcut">
|
||||
{parseBindingTokens(props.closeKeybind).map((token) => (
|
||||
<kbd class="am-menu-key">{token}</kbd>
|
||||
))}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<ClosableTabChrome
|
||||
label={props.label}
|
||||
tooltip={props.tooltip}
|
||||
icon={props.icon}
|
||||
iconStatus={props.iconStatus}
|
||||
class={props.class}
|
||||
focused={props.focused}
|
||||
active={props.active}
|
||||
closeable={props.closeable}
|
||||
showKeybind={props.showKeybind}
|
||||
keybind={props.keybind}
|
||||
closeKeybind={props.closeKeybind}
|
||||
role={props.role}
|
||||
selected={props.selected}
|
||||
tabIndex={props.tabIndex}
|
||||
onKeyDown={props.onKeyDown}
|
||||
onSelect={props.onSelect}
|
||||
onMiddleClick={props.onMiddleClick}
|
||||
onClose={props.onClose}
|
||||
trailing={props.trailing}
|
||||
/>
|
||||
</SessionTabMenu>
|
||||
</SortableTabContainer>
|
||||
)
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
DragOverlay,
|
||||
SortableProvider,
|
||||
closestCenter,
|
||||
type DragEvent,
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import { For, Show, createSignal, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import { ConstrainDragYAxis } from "../src/components/chat/TabDnd"
|
||||
import { createTabFocus } from "../src/utils/tab-navigation"
|
||||
import { useTabScroll } from "../src/utils/tab-scroll"
|
||||
import { setTabWidths } from "../src/utils/tab-widths"
|
||||
|
||||
const TABLIST = ".am-inspector-tablist"
|
||||
|
||||
type InspectorTabFocus = ReturnType<typeof createTabFocus>
|
||||
|
||||
interface InspectorTabStripApi {
|
||||
focus: InspectorTabFocus
|
||||
freeze: () => void
|
||||
release: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ids: Accessor<readonly string[]>
|
||||
active: Accessor<string | undefined>
|
||||
label: string
|
||||
renderTab: (id: string, api: InspectorTabStripApi) => JSX.Element
|
||||
overlay: (id: string) => string
|
||||
onSelect: (id: string) => void
|
||||
onReorder: (from: string, to: string) => void
|
||||
action?: (api: InspectorTabStripApi) => JSX.Element
|
||||
}
|
||||
|
||||
export const InspectorTabStrip: Component<Props> = (props) => {
|
||||
let host!: HTMLDivElement
|
||||
const scroll = useTabScroll(props.ids, props.active)
|
||||
const focus = createTabFocus({ ids: props.ids, select: props.onSelect, root: () => host })
|
||||
const [dragging, setDragging] = createSignal<{ id: string; width: number }>()
|
||||
const freeze = () => setTabWidths(true, host, TABLIST)
|
||||
const release = () => setTabWidths(false, host, TABLIST)
|
||||
const api = { focus, freeze, release }
|
||||
const start = (event: DragEvent) => {
|
||||
const id = event.draggable?.id
|
||||
if (typeof id !== "string") return
|
||||
const width = event.draggable?.layout.width ?? event.draggable?.node.getBoundingClientRect().width
|
||||
freeze()
|
||||
setDragging({ id, width })
|
||||
}
|
||||
const end = () => {
|
||||
setDragging(undefined)
|
||||
release()
|
||||
}
|
||||
const over = (event: DragEvent) => {
|
||||
const from = event.draggable?.id
|
||||
const to = event.droppable?.id
|
||||
if (typeof from !== "string" || typeof to !== "string") return
|
||||
props.onReorder(from, to)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={host}
|
||||
class="am-inspector-tabs"
|
||||
onPointerDown={(event) => {
|
||||
if (event.target instanceof Element && event.target.closest(".am-tab-close[data-tab-close]")) freeze()
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
if (!dragging()) release()
|
||||
}}
|
||||
>
|
||||
<DragDropProvider onDragStart={start} onDragEnd={end} onDragOver={over} collisionDetector={closestCenter}>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<div class="am-tab-scroll-area">
|
||||
<div class={`am-tab-fade am-tab-fade-left ${scroll.showLeft() ? "am-tab-fade-visible" : ""}`} />
|
||||
<div class="am-tab-list-wrap">
|
||||
<div
|
||||
class="am-inspector-tablist"
|
||||
ref={(el) => {
|
||||
scroll.setRef(el)
|
||||
}}
|
||||
role={props.ids().length > 0 ? "tablist" : undefined}
|
||||
aria-label={props.ids().length > 0 ? props.label : undefined}
|
||||
style={{ "--tab-count": `${props.ids().length}` } as JSX.CSSProperties}
|
||||
>
|
||||
<SortableProvider ids={[...props.ids()]}>
|
||||
<For each={props.ids()}>{(id) => props.renderTab(id, api)}</For>
|
||||
</SortableProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div class={`am-tab-fade am-tab-fade-right ${scroll.showRight() ? "am-tab-fade-visible" : ""}`} />
|
||||
</div>
|
||||
<DragOverlay>
|
||||
<Show when={dragging()}>
|
||||
{(tab) => (
|
||||
<div class="am-tab am-tab-overlay" style={{ width: `${tab().width}px` }}>
|
||||
<span class="am-tab-label">{props.overlay(tab().id)}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
{props.action?.(api)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { SidebarSectionHeader } from "./SidebarSectionHeader"
|
||||
import { WorktreeItem } from "./WorktreeItem"
|
||||
import { UnassignedSessionsSection } from "./UnassignedSessionsSection"
|
||||
import { ProjectActions } from "./ProjectActions"
|
||||
import { StatsSkeleton, WorktreeSkeleton } from "./Skeleton"
|
||||
import { applyTabOrder, firstOrderedTitle, reorderTabs } from "./tab-order"
|
||||
import { buildTopLevelItems, sortWorktrees, isGroupEnd, isGroupStart, isGrouped } from "./section-helpers"
|
||||
import { sectionAwareDetector } from "./section-dnd"
|
||||
@@ -290,89 +291,85 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
|
||||
|
||||
return (
|
||||
<div class="am-project-body" data-project-body={props.project.id}>
|
||||
<Show
|
||||
when={state()}
|
||||
fallback={
|
||||
<div class="am-project-loading">
|
||||
<Spinner class="am-worktree-spinner" />
|
||||
</div>
|
||||
}
|
||||
<button
|
||||
class="am-local-item"
|
||||
classList={{ "am-local-item-active": active() && props.selection === "local" }}
|
||||
data-sidebar-id={`${props.project.id}:local`}
|
||||
onClick={() => props.onSelectLocal(props.project.id)}
|
||||
>
|
||||
<button
|
||||
class="am-local-item"
|
||||
classList={{ "am-local-item-active": active() && props.selection === "local" }}
|
||||
data-sidebar-id={`${props.project.id}:local`}
|
||||
onClick={() => props.onSelectLocal(props.project.id)}
|
||||
>
|
||||
<Show when={!props.localBusy?.()} fallback={<Spinner class="am-worktree-spinner" />}>
|
||||
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
|
||||
<path d="M6 16.5H14" stroke="currentColor" stroke-linecap="square" />
|
||||
<path d="M10 13.5V16.5" stroke="currentColor" />
|
||||
</svg>
|
||||
<Show when={!props.localBusy?.()} fallback={<Spinner class="am-worktree-spinner" />}>
|
||||
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
|
||||
<path d="M6 16.5H14" stroke="currentColor" stroke-linecap="square" />
|
||||
<path d="M10 13.5V16.5" stroke="currentColor" />
|
||||
</svg>
|
||||
</Show>
|
||||
<div class="am-local-text">
|
||||
<span class="am-local-label">{props.t("agentManager.local")}</span>
|
||||
<Show when={props.local === undefined}>
|
||||
<span class="am-local-branch-skeleton" />
|
||||
</Show>
|
||||
<div class="am-local-text">
|
||||
<span class="am-local-label">{props.t("agentManager.local")}</span>
|
||||
<Show when={props.local?.branch}>
|
||||
<span class="am-local-branch">{props.local!.branch}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="am-wt-actions-cell">
|
||||
<Show
|
||||
when={
|
||||
props.local &&
|
||||
(props.local.additions || props.local.deletions || props.local.ahead || props.local.behind)
|
||||
}
|
||||
>
|
||||
<div class="am-worktree-stats">
|
||||
<Show when={props.local!.behind}>
|
||||
<span class="am-worktree-behind">↓{props.local!.behind}</span>
|
||||
</Show>
|
||||
<Show when={props.local!.ahead}>
|
||||
<span class="am-worktree-commits">↑{props.local!.ahead}</span>
|
||||
</Show>
|
||||
<Show when={props.local!.additions}>
|
||||
<span class="am-stat-additions">+{props.local!.additions}</span>
|
||||
</Show>
|
||||
<Show when={props.local!.deletions}>
|
||||
<span class="am-stat-deletions">−{props.local!.deletions}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="am-wt-hover-actions">
|
||||
<Show when={props.shortcutMap?.().get(`${props.project.id}:local`)}>
|
||||
{(shortcut) => (
|
||||
<span class="am-shortcut-badge">
|
||||
{isMac ? "⌘" : "Ctrl+"}
|
||||
{shortcut()}
|
||||
</span>
|
||||
)}
|
||||
<Show when={props.local?.branch}>
|
||||
<span class="am-local-branch">{props.local!.branch}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="am-wt-actions-cell">
|
||||
<Show when={props.local === undefined}>
|
||||
<StatsSkeleton />
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
props.local && (props.local.additions || props.local.deletions || props.local.ahead || props.local.behind)
|
||||
}
|
||||
>
|
||||
<div class="am-worktree-stats">
|
||||
<Show when={props.local!.behind}>
|
||||
<span class="am-worktree-behind">↓{props.local!.behind}</span>
|
||||
</Show>
|
||||
<Show when={props.local!.ahead}>
|
||||
<span class="am-worktree-commits">↑{props.local!.ahead}</span>
|
||||
</Show>
|
||||
<Show when={props.local!.additions}>
|
||||
<span class="am-stat-additions">+{props.local!.additions}</span>
|
||||
</Show>
|
||||
<Show when={props.local!.deletions}>
|
||||
<span class="am-stat-deletions">−{props.local!.deletions}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="am-wt-hover-actions">
|
||||
<Show when={props.shortcutMap?.().get(`${props.project.id}:local`)}>
|
||||
{(shortcut) => (
|
||||
<span class="am-shortcut-badge">
|
||||
{isMac ? "⌘" : "Ctrl+"}
|
||||
{shortcut()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="am-section">
|
||||
<SidebarSectionHeader
|
||||
class="am-section-header"
|
||||
label={<span class="am-section-label">{props.t("agentManager.section.worktrees")}</span>}
|
||||
actions={
|
||||
<ProjectActions
|
||||
branch={state()?.defaultBaseBranch ?? props.local?.branch ?? "main"}
|
||||
bindings={props.bindings}
|
||||
loaded={state() !== undefined}
|
||||
t={props.t}
|
||||
onCreate={() => post({ type: "agentManager.createWorktree" })}
|
||||
onNew={() => props.onNewWorktree(props.project.id)}
|
||||
onSection={() => createSection()}
|
||||
onSetup={() => post({ type: "agentManager.configureSetupScript" })}
|
||||
onBranch={() =>
|
||||
props.onDefaultBranch(props.project.id, state()?.defaultBaseBranch, props.local?.branch)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div class="am-worktree-list">
|
||||
<div class="am-section">
|
||||
<SidebarSectionHeader
|
||||
class="am-section-header"
|
||||
label={<span class="am-section-label">{props.t("agentManager.section.worktrees")}</span>}
|
||||
actions={
|
||||
<ProjectActions
|
||||
branch={state()?.defaultBaseBranch ?? props.local?.branch ?? "main"}
|
||||
bindings={props.bindings}
|
||||
loaded={state() !== undefined}
|
||||
t={props.t}
|
||||
onCreate={() => post({ type: "agentManager.createWorktree" })}
|
||||
onNew={() => props.onNewWorktree(props.project.id)}
|
||||
onSection={() => createSection()}
|
||||
onSetup={() => post({ type: "agentManager.configureSetupScript" })}
|
||||
onBranch={() => props.onDefaultBranch(props.project.id, state()?.defaultBaseBranch, props.local?.branch)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div class="am-worktree-list">
|
||||
<Show when={state()} fallback={<WorktreeSkeleton />}>
|
||||
<DragDropProvider
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
@@ -435,21 +432,26 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
|
||||
})()}
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UnassignedSessionsSection
|
||||
sessions={localSessions}
|
||||
loaded={() => props.sessions !== undefined}
|
||||
collapsed={() => state()!.sessionsCollapsed === true}
|
||||
active={() => undefined}
|
||||
onToggle={() => post({ type: "agentManager.setSessionsCollapsed", collapsed: !state()!.sessionsCollapsed })}
|
||||
onSelect={(sessionId) => props.onSelectSession(props.project.id, sessionId)}
|
||||
onPromote={(sessionId) => post({ type: "agentManager.promoteSession", sessionId })}
|
||||
onOpen={(sessionId) => post({ type: "agentManager.openLocally", sessionId })}
|
||||
sidebarId={(sessionId) => `${props.project.id}:sess:${sessionId}`}
|
||||
/>
|
||||
</Show>
|
||||
<UnassignedSessionsSection
|
||||
sessions={localSessions}
|
||||
loaded={() => props.sessions !== undefined && state() !== undefined}
|
||||
collapsed={() => state()?.sessionsCollapsed === true}
|
||||
disabled={state() === undefined}
|
||||
active={() => undefined}
|
||||
onToggle={() => {
|
||||
const current = state()
|
||||
if (!current) return
|
||||
post({ type: "agentManager.setSessionsCollapsed", collapsed: !current.sessionsCollapsed })
|
||||
}}
|
||||
onSelect={(sessionId) => props.onSelectSession(props.project.id, sessionId)}
|
||||
onPromote={(sessionId) => post({ type: "agentManager.promoteSession", sessionId })}
|
||||
onOpen={(sessionId) => post({ type: "agentManager.openLocally", sessionId })}
|
||||
sidebarId={(sessionId) => `${props.project.id}:sess:${sessionId}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { SidebarSectionHeader } from "./SidebarSectionHeader"
|
||||
import { WorktreeItem } from "./WorktreeItem"
|
||||
import { WorktreeSectionActions } from "./WorktreeSectionActions"
|
||||
import { UnassignedSessionsSection } from "./UnassignedSessionsSection"
|
||||
import { StatsSkeleton, WorktreeSkeleton } from "./Skeleton"
|
||||
import type { SidebarSearchMenuRef } from "./SidebarSearchMenu"
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
|
||||
@@ -126,10 +127,7 @@ export const SidebarBody: Component<SidebarBodyProps> = (props) => {
|
||||
</div>
|
||||
<div class="am-wt-actions-cell">
|
||||
<Show when={props.localStats() === undefined}>
|
||||
<div class="am-worktree-stats-skeleton">
|
||||
<div class="am-worktree-stats-skeleton-row" />
|
||||
<div class="am-worktree-stats-skeleton-row" style={{ width: "70%" }} />
|
||||
</div>
|
||||
<StatsSkeleton />
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
@@ -212,17 +210,7 @@ export const SidebarBody: Component<SidebarBodyProps> = (props) => {
|
||||
}
|
||||
/>
|
||||
<div class="am-worktree-list">
|
||||
<Show
|
||||
when={props.worktreesLoaded() && props.sessionsLoaded()}
|
||||
fallback={
|
||||
<div class="am-skeleton-list">
|
||||
<div class="am-skeleton-wt">
|
||||
<div class="am-skeleton-wt-icon" />
|
||||
<div class="am-skeleton-wt-text" style={{ width: "60%" }} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={props.worktreesLoaded() && props.sessionsLoaded()} fallback={<WorktreeSkeleton />}>
|
||||
<Show when={!props.isGitRepo()}>
|
||||
<div class="am-not-git-notice">
|
||||
<Icon name="warning" size="small" />
|
||||
|
||||
@@ -10,6 +10,7 @@ interface Props {
|
||||
class?: string
|
||||
title?: string
|
||||
ariaLabel?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** Shared layout for sidebar headings with a fixed leading control column. */
|
||||
@@ -19,7 +20,7 @@ export const SidebarSectionHeader: Component<Props> = (props) => {
|
||||
class={`am-sidebar-header${props.onToggle ? " am-sidebar-header-toggleable" : ""}${props.class ? ` ${props.class}` : ""}`}
|
||||
title={props.title}
|
||||
onClick={(event) => {
|
||||
if (event.button === 0) props.onToggle?.()
|
||||
if (event.button === 0 && !props.disabled) props.onToggle?.()
|
||||
}}
|
||||
>
|
||||
<div class="am-sidebar-header-main">
|
||||
@@ -29,9 +30,10 @@ export const SidebarSectionHeader: Component<Props> = (props) => {
|
||||
type="button"
|
||||
aria-expanded={props.expanded}
|
||||
aria-label={props.ariaLabel}
|
||||
disabled={props.disabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onToggle?.()
|
||||
if (!props.disabled) props.onToggle?.()
|
||||
}}
|
||||
>
|
||||
<span class="am-sidebar-header-chevron" aria-hidden="true">
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { For, type Component } from "solid-js"
|
||||
|
||||
/** Staggered widths so stacked placeholder rows read as a list, not a striped block. */
|
||||
const BRANCH = ["62%", "44%", "70%", "52%"]
|
||||
const SUB = ["38%", "30%", "46%", "34%"]
|
||||
const TITLE = ["70%", "55%", "65%", "48%"]
|
||||
|
||||
const rows = (count: number) => Array.from({ length: count }, (_, index) => index)
|
||||
|
||||
/** Offset each row's pulse so any row count keeps the wave effect. */
|
||||
const delay = (index: number) => `${index * 0.12}s`
|
||||
|
||||
/** Placeholder worktree rows shown until a project's worktree list arrives. */
|
||||
export const WorktreeSkeleton: Component<{ count?: number }> = (props) => (
|
||||
<div class="am-skeleton-list">
|
||||
<For each={rows(props.count ?? 3)}>
|
||||
{(index) => (
|
||||
<div class="am-skeleton-wt">
|
||||
<div class="am-skeleton-wt-icon" style={{ "animation-delay": delay(index) }} />
|
||||
<div class="am-skeleton-wt-lines">
|
||||
<div
|
||||
class="am-skeleton-wt-text"
|
||||
style={{ width: BRANCH[index % BRANCH.length], "animation-delay": delay(index) }}
|
||||
/>
|
||||
<div
|
||||
class="am-skeleton-wt-sub"
|
||||
style={{ width: SUB[index % SUB.length], "animation-delay": delay(index) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
|
||||
/** Placeholder session rows shown until a project's session list arrives. */
|
||||
export const SessionSkeleton: Component<{ count?: number }> = (props) => (
|
||||
<div class="am-skeleton-list">
|
||||
<For each={rows(props.count ?? 3)}>
|
||||
{(index) => (
|
||||
<div class="am-skeleton-session">
|
||||
<div
|
||||
class="am-skeleton-session-title"
|
||||
style={{ width: TITLE[index % TITLE.length], "animation-delay": delay(index) }}
|
||||
/>
|
||||
<div class="am-skeleton-session-time" style={{ "animation-delay": delay(index) }} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
|
||||
/** Placeholder for the two-line git stats column on a local or worktree row. */
|
||||
export const StatsSkeleton: Component = () => (
|
||||
<div class="am-worktree-stats-skeleton">
|
||||
<div class="am-worktree-stats-skeleton-row" />
|
||||
<div class="am-worktree-stats-skeleton-row" style={{ width: "70%" }} />
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Read-only subagent chats for the Agent Manager inspector.
|
||||
*
|
||||
* The nested session provider keeps the parent chat selection independent from
|
||||
* the child transcript while still consuming the same webview event stream.
|
||||
*/
|
||||
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { createEffect, type Accessor, type Component } from "solid-js"
|
||||
import { DataBridge } from "../src/App"
|
||||
import { ChatView } from "../src/components/chat"
|
||||
import { SessionProvider, useSession } from "../src/context/session"
|
||||
import { SortableClosableTab } from "./ClosableTab"
|
||||
import { InspectorTabStrip } from "./InspectorTabStrip"
|
||||
import type { SubagentTab } from "./subagent-tabs"
|
||||
|
||||
interface Props {
|
||||
tabs: Accessor<SubagentTab[]>
|
||||
active: Accessor<string | undefined>
|
||||
visible: Accessor<boolean>
|
||||
nextKeybind: string
|
||||
closeKeybind: string
|
||||
onSelect: (id: string) => void
|
||||
onClose: (id: string) => void
|
||||
onCloseOthers: (id: string) => void
|
||||
onReorder: (from: string, to: string) => void
|
||||
onClosePanel: () => void
|
||||
}
|
||||
|
||||
const SubagentChat: Component<{ active: Accessor<string | undefined> }> = (props) => {
|
||||
const session = useSession()
|
||||
|
||||
createEffect(() => {
|
||||
const id = props.active()
|
||||
if (!id) return
|
||||
session.selectSession(id, { focus: false })
|
||||
})
|
||||
|
||||
return (
|
||||
<DataBridge>
|
||||
<ChatView readonly promptBoxId="agent-manager:subagent" />
|
||||
</DataBridge>
|
||||
)
|
||||
}
|
||||
|
||||
const SubagentContent: Component<Props> = (props) => {
|
||||
const session = useSession()
|
||||
const ids = () => props.tabs().map((tab) => tab.id)
|
||||
const title = (id: string) => props.tabs().find((tab) => tab.id === id)?.title ?? "Sub-agent"
|
||||
const close = (id: string, focus: { restore: () => void }) => {
|
||||
props.onClose(id)
|
||||
session.releaseSession(id)
|
||||
if (ids().length > 0) focus.restore()
|
||||
}
|
||||
const closeOthers = (id: string) => {
|
||||
const gone = ids().filter((item) => item !== id)
|
||||
props.onCloseOthers(id)
|
||||
for (const item of gone) session.releaseSession(item)
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
class="am-subagent-panel"
|
||||
classList={{ "am-subagent-panel-visible": props.visible() }}
|
||||
aria-label="Subagents"
|
||||
aria-hidden={!props.visible()}
|
||||
inert={!props.visible()}
|
||||
>
|
||||
<header class="am-subagent-header">
|
||||
<div class="am-subagent-heading">
|
||||
<Icon name="task" size="small" />
|
||||
<span>Subagents</span>
|
||||
<span class="am-subagent-count">{props.tabs().length}</span>
|
||||
</div>
|
||||
<IconButton
|
||||
icon="x"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label="Close subagents panel"
|
||||
onClick={props.onClosePanel}
|
||||
/>
|
||||
</header>
|
||||
<InspectorTabStrip
|
||||
ids={ids}
|
||||
active={props.active}
|
||||
label="Subagent sessions"
|
||||
overlay={title}
|
||||
onSelect={props.onSelect}
|
||||
onReorder={props.onReorder}
|
||||
renderTab={(id, api) => {
|
||||
const label = title(id)
|
||||
return (
|
||||
<SortableClosableTab
|
||||
id={id}
|
||||
label={label}
|
||||
tooltip={label}
|
||||
icon="task"
|
||||
showKeybind={false}
|
||||
keybind={props.active() === id ? "" : props.nextKeybind}
|
||||
closeKeybind={props.closeKeybind}
|
||||
active={props.active() === id}
|
||||
role="tab"
|
||||
selected={props.active() === id}
|
||||
tabIndex={props.active() === id ? 0 : -1}
|
||||
onKeyDown={(event) => api.focus.key(id, event)}
|
||||
onSelect={() => props.onSelect(id)}
|
||||
onMiddleClick={(event) => {
|
||||
if (event.button !== 1) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
close(id, api.focus)
|
||||
}}
|
||||
onClose={() => close(id, api.focus)}
|
||||
onCloseOthers={() => closeOthers(id)}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<div class="am-subagent-chat">
|
||||
<SubagentChat active={props.active} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export const SubagentPanel: Component<Props> = (props) => (
|
||||
<SessionProvider>
|
||||
<SubagentContent {...props} />
|
||||
</SessionProvider>
|
||||
)
|
||||
@@ -243,19 +243,14 @@ export const TabBar: Component<TabBarProps> = (props) => (
|
||||
title={props.t("agentManager.diff.toggle")}
|
||||
>
|
||||
<Icon name="layers" size="small" />
|
||||
<Show when={props.prStatus()}>
|
||||
{(pr) => (
|
||||
<Show when={pr().additions > 0 || pr().deletions > 0}>
|
||||
<span class="am-diff-toggle-stats">
|
||||
<Show when={pr().additions > 0}>
|
||||
<span class="am-stat-additions">+{pr().additions}</span>
|
||||
</Show>
|
||||
<Show when={pr().deletions > 0}>
|
||||
<span class="am-stat-deletions">−{pr().deletions}</span>
|
||||
</Show>
|
||||
</span>
|
||||
<Show when={hasChanges()}>
|
||||
<span class="am-diff-toggle-stats">
|
||||
<Show when={stats()!.files > 0}>
|
||||
<span class="am-stat-files">{stats()!.files}f</span>
|
||||
</Show>
|
||||
)}
|
||||
<span class="am-stat-additions">+{stats()!.additions}</span>
|
||||
<span class="am-stat-deletions">−{stats()!.deletions}</span>
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
</TooltipKeybind>
|
||||
|
||||
@@ -7,11 +7,13 @@ import type { SessionInfo } from "../src/types/messages"
|
||||
import { useLanguage } from "../src/context/language"
|
||||
import { formatRelativeDate } from "../src/utils/date"
|
||||
import { SidebarSectionHeader } from "./SidebarSectionHeader"
|
||||
import { SessionSkeleton } from "./Skeleton"
|
||||
|
||||
interface Props {
|
||||
sessions: Accessor<SessionInfo[]>
|
||||
loaded: Accessor<boolean>
|
||||
collapsed: Accessor<boolean>
|
||||
disabled?: boolean
|
||||
active: Accessor<string | undefined>
|
||||
onToggle: () => void
|
||||
onSelect: (id: string) => void
|
||||
@@ -33,31 +35,14 @@ export const UnassignedSessionsSection: Component<Props> = (props) => {
|
||||
<SidebarSectionHeader
|
||||
class="am-section-header am-section-toggle"
|
||||
expanded={!props.collapsed()}
|
||||
disabled={props.disabled}
|
||||
ariaLabel={t("agentManager.section.sessions")}
|
||||
label={<span class="am-section-label">{t("agentManager.section.sessions")}</span>}
|
||||
onToggle={props.onToggle}
|
||||
/>
|
||||
<Show when={!props.collapsed()}>
|
||||
<div class="am-list">
|
||||
<Show
|
||||
when={props.loaded()}
|
||||
fallback={
|
||||
<div class="am-skeleton-list">
|
||||
<div class="am-skeleton-session">
|
||||
<div class="am-skeleton-session-title" style={{ width: "70%" }} />
|
||||
<div class="am-skeleton-session-time" />
|
||||
</div>
|
||||
<div class="am-skeleton-session">
|
||||
<div class="am-skeleton-session-title" style={{ width: "55%" }} />
|
||||
<div class="am-skeleton-session-time" />
|
||||
</div>
|
||||
<div class="am-skeleton-session">
|
||||
<div class="am-skeleton-session-title" style={{ width: "65%" }} />
|
||||
<div class="am-skeleton-session-time" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={props.loaded()} fallback={<SessionSkeleton />}>
|
||||
<For each={props.sessions()}>
|
||||
{(session) => (
|
||||
<ContextMenu>
|
||||
|
||||
@@ -237,6 +237,11 @@ html[data-theme="kilo-vscode"]
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.am-sidebar-header-toggle:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.am-sidebar-header-toggle:hover {
|
||||
color: var(--text-base);
|
||||
background: var(--surface-inset-base-hover);
|
||||
@@ -407,10 +412,16 @@ html[data-theme="kilo-vscode"]
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.am-project-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
/* Stands in for .am-local-branch so the local row keeps its two-line height
|
||||
while a project's git state is still loading. */
|
||||
.am-local-branch-skeleton {
|
||||
width: 72px;
|
||||
max-width: 100%;
|
||||
height: 8px;
|
||||
margin: 2px 0;
|
||||
border-radius: 3px;
|
||||
background: var(--text-base);
|
||||
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Collapsible section toggle */
|
||||
@@ -1503,7 +1514,7 @@ html[data-theme="kilo-vscode"]
|
||||
color: var(--vscode-testing-iconFailed, #f87171);
|
||||
}
|
||||
|
||||
.am-terminal-tab-spinner {
|
||||
.am-tab-spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
@@ -1771,6 +1782,10 @@ body.am-wt-dragging-active * {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.am-chat-wrapper.am-chat-wrapper-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.am-readonly-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3593,33 +3608,48 @@ body.am-wt-dragging-active * {
|
||||
animation: am-fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
/* Worktree skeleton — matches .am-worktree-item layout */
|
||||
/* Worktree skeleton — mirrors .am-worktree-item: icon column plus a label row
|
||||
and a branch row, so settling into real rows costs no vertical shift. */
|
||||
.am-skeleton-wt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 10px;
|
||||
padding: 6px 6px;
|
||||
min-height: 36px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.am-skeleton-wt-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 3px;
|
||||
border-radius: 3px;
|
||||
background: var(--text-base);
|
||||
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.am-skeleton-wt-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.am-skeleton-wt-text {
|
||||
height: 13px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
background: var(--text-base);
|
||||
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.am-skeleton-wt:nth-child(2) .am-skeleton-wt-icon,
|
||||
.am-skeleton-wt:nth-child(2) .am-skeleton-wt-text {
|
||||
animation-delay: 0.15s;
|
||||
.am-skeleton-wt-sub {
|
||||
height: 7px;
|
||||
border-radius: 3px;
|
||||
background: var(--text-base);
|
||||
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Session skeleton — matches .am-item layout */
|
||||
@@ -3647,16 +3677,6 @@ body.am-wt-dragging-active * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.am-skeleton-session:nth-child(2) .am-skeleton-session-title,
|
||||
.am-skeleton-session:nth-child(2) .am-skeleton-session-time {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.am-skeleton-session:nth-child(3) .am-skeleton-session-title,
|
||||
.am-skeleton-session:nth-child(3) .am-skeleton-session-time {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
/* Tab switcher for New/Import tabs in the dialog */
|
||||
|
||||
.am-tab-switcher {
|
||||
@@ -4821,8 +4841,9 @@ body.vscode-high-contrast-light {
|
||||
}
|
||||
}
|
||||
|
||||
/* Experimental terminal tabs (feature-flagged) */
|
||||
/* Shared sortable inspector tabs. */
|
||||
|
||||
.am-tab-closable,
|
||||
.am-tab-terminal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4830,12 +4851,13 @@ body.vscode-high-contrast-light {
|
||||
border-left: 1px solid var(--border-weak-base);
|
||||
}
|
||||
|
||||
.am-tab-closable [data-component="icon"],
|
||||
.am-tab-terminal [data-component="icon"] {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.am-tab-terminal-focused {
|
||||
.am-tab-focused {
|
||||
background: var(--surface-base-hover);
|
||||
}
|
||||
|
||||
@@ -4958,15 +4980,14 @@ body.vscode-high-contrast-light {
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
/* Side terminal tab strip — one row of tabs reusing the top bar's
|
||||
.am-tab chrome, plus the "+" action. Height matches .am-diff-header
|
||||
/* Inspector tab strip — one row of tabs reusing the top bar's
|
||||
.am-tab chrome, plus an optional action. Height matches .am-diff-header
|
||||
(32px) so switching inspector modes does not shift the panel chrome.
|
||||
No vertical padding: tabs fill the strip like they fill .am-tab-bar,
|
||||
which also keeps the "+" optically centered. The strip itself never
|
||||
scrolls; the tab list does, so a narrow panel never pushes the "+"
|
||||
action out of view (same split as .am-tab-list-wrap /
|
||||
.am-tab-add-wrap). */
|
||||
.am-side-terminal-tabs {
|
||||
which also keeps actions optically centered. The strip itself never
|
||||
scrolls; the tab list does, so a narrow panel never pushes an action out
|
||||
of view (same split as .am-tab-list-wrap / .am-tab-add-wrap). */
|
||||
.am-inspector-tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 32px;
|
||||
@@ -4981,10 +5002,10 @@ body.vscode-high-contrast-light {
|
||||
|
||||
/* Same width model as .am-tab-list: tabs claim an equal share of the
|
||||
strip up to a maximum, and the list itself only grows as wide as its
|
||||
tabs, so the "+" action stays glued to the last tab instead of
|
||||
tabs, so an action stays glued to the last tab instead of
|
||||
drifting to the far edge of a wide panel. The cap is smaller than the
|
||||
top bar's 240px because the panel is narrow. */
|
||||
.am-side-terminal-tablist {
|
||||
.am-inspector-tablist {
|
||||
--am-tab-max-width: 180px;
|
||||
--am-tab-width: clamp(72px, calc(100% / var(--tab-count, 1)), var(--am-tab-max-width));
|
||||
display: flex;
|
||||
@@ -5000,17 +5021,16 @@ body.vscode-high-contrast-light {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.am-side-terminal-tablist::-webkit-scrollbar {
|
||||
.am-inspector-tablist::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.am-side-terminal-tablist[data-tab-widths-frozen] .am-tab-sortable {
|
||||
.am-inspector-tablist[data-tab-widths-frozen] .am-tab-sortable {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* The left divider marks a terminal among session tabs in the top bar.
|
||||
Every tab here is a terminal, so it would just be noise. */
|
||||
.am-side-terminal-tablist .am-tab-terminal {
|
||||
/* The left divider belongs to the top-level mixed tab bar. */
|
||||
.am-inspector-tablist .am-tab-closable {
|
||||
border-left-color: transparent;
|
||||
}
|
||||
|
||||
@@ -5034,6 +5054,69 @@ body.vscode-high-contrast-light {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Subagent inspector panel. It remains mounted while another inspector mode
|
||||
is active so switching back keeps the child transcript and scroll position. */
|
||||
.am-subagent-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
background: var(--surface-base);
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.am-subagent-panel-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.am-subagent-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 4px 0 8px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-weak-base);
|
||||
background: var(--surface-base);
|
||||
}
|
||||
|
||||
.am-subagent-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
color: var(--text-base);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.am-subagent-count {
|
||||
color: var(--text-weak);
|
||||
font-size: var(--kilo-font-size-10);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.am-subagent-chat {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.am-subagent-chat > [data-component="data-provider"],
|
||||
.am-subagent-chat [class~="chat-view"] {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.am-terminal-host {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
import { render } from "solid-js/web"
|
||||
import "@kilocode/kilo-ui/styles"
|
||||
import "../src/styles/chat.css"
|
||||
import { registerExpandedTaskTool } from "../src/components/chat/TaskToolExpanded"
|
||||
import { registerVscodeToolOverrides } from "../src/components/chat/VscodeToolOverrides"
|
||||
import { AgentManagerApp } from "./AgentManagerApp"
|
||||
|
||||
registerExpandedTaskTool()
|
||||
registerVscodeToolOverrides()
|
||||
|
||||
const root = document.getElementById("root")
|
||||
if (root) {
|
||||
render(() => <AgentManagerApp />, root)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* draft, terminal, or review) and falls back to the first available session.
|
||||
*/
|
||||
|
||||
import { batch } from "solid-js"
|
||||
import { LOCAL } from "./navigate"
|
||||
|
||||
interface TermState {
|
||||
@@ -39,23 +40,28 @@ export interface SelectionActionDeps<T extends SessionLike> {
|
||||
/** Select the Local context: restore its remembered tab or fall back to the first session/draft. */
|
||||
export function selectLocalAction<T extends SessionLike>(deps: SelectionActionDeps<T>, locals: T[]): void {
|
||||
deps.saveTabMemory()
|
||||
deps.setReviewActive(false)
|
||||
deps.setSelection(LOCAL)
|
||||
deps.post({ type: "agentManager.requestRepoInfo" })
|
||||
const remembered = deps.tabMemory()[LOCAL]
|
||||
if (deps.terms.hasRemembered(deps.nsKey(LOCAL), remembered)) return deps.activateTerminal(remembered!)
|
||||
deps.terms.setActiveId(undefined)
|
||||
const target = remembered ? locals.find((s) => s.id === remembered) : undefined
|
||||
const fallback = target ?? locals[0]
|
||||
if (fallback && !deps.isPending(fallback.id)) {
|
||||
deps.setActivePendingId(undefined)
|
||||
deps.selectSession(fallback.id)
|
||||
} else {
|
||||
deps.setActivePendingId(fallback && deps.isPending(fallback.id) ? fallback.id : undefined)
|
||||
deps.clearSession()
|
||||
deps.post({ type: "agentManager.showExistingLocalTerminal" })
|
||||
}
|
||||
deps.setReviewActive(deps.isReviewTab(remembered, LOCAL))
|
||||
batch(() => {
|
||||
deps.setReviewActive(false)
|
||||
deps.setSelection(LOCAL)
|
||||
if (deps.terms.hasRemembered(deps.nsKey(LOCAL), remembered)) {
|
||||
deps.activateTerminal(remembered!)
|
||||
return
|
||||
}
|
||||
deps.terms.setActiveId(undefined)
|
||||
const target = remembered ? locals.find((s) => s.id === remembered) : undefined
|
||||
const fallback = target ?? locals[0]
|
||||
if (fallback && !deps.isPending(fallback.id)) {
|
||||
deps.setActivePendingId(undefined)
|
||||
deps.selectSession(fallback.id)
|
||||
} else {
|
||||
deps.setActivePendingId(fallback && deps.isPending(fallback.id) ? fallback.id : undefined)
|
||||
deps.clearSession()
|
||||
deps.post({ type: "agentManager.showExistingLocalTerminal" })
|
||||
}
|
||||
deps.setReviewActive(deps.isReviewTab(remembered, LOCAL))
|
||||
})
|
||||
}
|
||||
|
||||
/** Select a worktree: restore its remembered tab or fall back to its first session. */
|
||||
@@ -65,13 +71,18 @@ export function selectWorktreeAction<T extends SessionLike>(
|
||||
sessions: T[],
|
||||
): void {
|
||||
deps.saveTabMemory()
|
||||
deps.setSelection(worktreeId)
|
||||
const remembered = deps.tabMemory()[worktreeId]
|
||||
if (deps.terms.hasRemembered(deps.nsKey(worktreeId), remembered)) return deps.activateTerminal(remembered!)
|
||||
deps.terms.setActiveId(undefined)
|
||||
const target = remembered ? sessions.find((s) => s.id === remembered) : undefined
|
||||
const fallback = target ?? sessions[0]
|
||||
if (fallback) deps.selectSession(fallback.id)
|
||||
else deps.resetSession()
|
||||
deps.setReviewActive(deps.isReviewTab(remembered, worktreeId))
|
||||
batch(() => {
|
||||
deps.setSelection(worktreeId)
|
||||
if (deps.terms.hasRemembered(deps.nsKey(worktreeId), remembered)) {
|
||||
deps.activateTerminal(remembered!)
|
||||
return
|
||||
}
|
||||
deps.terms.setActiveId(undefined)
|
||||
const target = remembered ? sessions.find((s) => s.id === remembered) : undefined
|
||||
const fallback = target ?? sessions[0]
|
||||
if (fallback) deps.selectSession(fallback.id)
|
||||
else deps.resetSession()
|
||||
deps.setReviewActive(deps.isReviewTab(remembered, worktreeId))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum SidePanel {
|
||||
Diff = "diff",
|
||||
PR = "pr",
|
||||
Terminal = "terminal",
|
||||
Subagents = "subagents",
|
||||
}
|
||||
|
||||
function viewportWidth(viewport: number): number {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { batch, createSignal, type Accessor } from "solid-js"
|
||||
import { reorderTabs } from "../src/utils/tab-order"
|
||||
|
||||
export interface SubagentTab {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
interface Options {
|
||||
current: Accessor<string | undefined>
|
||||
sync: (id: string, parentID?: string) => void
|
||||
unsync: (id: string) => void
|
||||
show: () => void
|
||||
hide: () => void
|
||||
}
|
||||
|
||||
export function createSubagentTabs(opts: Options) {
|
||||
const [tabs, setTabs] = createSignal<SubagentTab[]>([])
|
||||
const [active, setActive] = createSignal<string>()
|
||||
|
||||
const open = (id: string, title?: string, parentID?: string) => {
|
||||
if (!id) return
|
||||
const label = title?.trim() || "Sub-agent"
|
||||
const existing = tabs().some((tab) => tab.id === id)
|
||||
batch(() => {
|
||||
setTabs((prev) => {
|
||||
const existing = prev.find((tab) => tab.id === id)
|
||||
if (!existing) return [...prev, { id, title: label }]
|
||||
if (title?.trim() && existing.title !== label) {
|
||||
return prev.map((tab) => (tab.id === id ? { ...tab, title: label } : tab))
|
||||
}
|
||||
return prev
|
||||
})
|
||||
setActive(id)
|
||||
opts.show()
|
||||
})
|
||||
if (!existing) opts.sync(id, parentID ?? opts.current())
|
||||
}
|
||||
|
||||
const select = (id: string) => {
|
||||
if (!tabs().some((tab) => tab.id === id)) return
|
||||
setActive(id)
|
||||
opts.show()
|
||||
}
|
||||
|
||||
const close = (id: string) => {
|
||||
const current = tabs()
|
||||
const index = current.findIndex((tab) => tab.id === id)
|
||||
if (index < 0) return
|
||||
const next = current.filter((tab) => tab.id !== id)
|
||||
opts.unsync(id)
|
||||
setTabs(next)
|
||||
if (active() !== id) return
|
||||
const replacement = next[Math.min(index, next.length - 1)]
|
||||
if (replacement) {
|
||||
setActive(replacement.id)
|
||||
return
|
||||
}
|
||||
setActive(undefined)
|
||||
opts.hide()
|
||||
}
|
||||
|
||||
const closeOthers = (id: string) => {
|
||||
if (!tabs().some((tab) => tab.id === id)) return
|
||||
for (const tab of tabs()) {
|
||||
if (tab.id !== id) opts.unsync(tab.id)
|
||||
}
|
||||
setTabs((prev) => prev.filter((tab) => tab.id === id))
|
||||
setActive(id)
|
||||
opts.show()
|
||||
}
|
||||
|
||||
const reorder = (from: string, to: string) => {
|
||||
const order = reorderTabs(
|
||||
tabs().map((tab) => tab.id),
|
||||
from,
|
||||
to,
|
||||
)
|
||||
if (!order) return
|
||||
setTabs((prev) => {
|
||||
const lookup = new Map(prev.map((tab) => [tab.id, tab]))
|
||||
return order.flatMap((id) => {
|
||||
const tab = lookup.get(id)
|
||||
return tab ? [tab] : []
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return { tabs, active, open, select, close, closeOthers, reorder }
|
||||
}
|
||||
@@ -1,48 +1,26 @@
|
||||
/**
|
||||
* Right-side terminal panel for the Agent Manager inspector.
|
||||
*
|
||||
* Lives inside the shared `.am-diff-panel-wrapper` host next to the diff
|
||||
* and PR panels, so all three inspector modes share one resize handle
|
||||
* and one width.
|
||||
* Lives inside the shared inspector host next to diff, PR, and subagent
|
||||
* panels, so every mode uses the same persisted resize width. The tab row is
|
||||
* the shared inspector strip used by subagents as well.
|
||||
*
|
||||
* A context can own several side terminals. The header is a tab strip
|
||||
* that reuses the top tab bar's whole chrome: `SortableTerminalTab`
|
||||
* (icon, title, X close, right-click Close / Close Others), the same
|
||||
* `@thisbeyond/solid-dnd` reorder stack, the same overflow scrolling
|
||||
* with edge fades, the same width freeze while tabs close, and the same
|
||||
* arrow-key tab navigation, so a terminal behaves identically in
|
||||
* either surface. Reorder state lives in the terminal state, so it is
|
||||
* preserved across sidebar context switches for the webview's lifetime.
|
||||
*
|
||||
* The `+` action sits directly after the last tab (outside the
|
||||
* scrolling region, like the tab bar's `am-tab-add-wrap`), so it never
|
||||
* scrolls away and never drifts to the far edge of a wide panel. The
|
||||
* strip stays visible even when empty so `+` is always reachable.
|
||||
*
|
||||
* Visibility is opacity-based, never unmount: the xterm render loop
|
||||
* dies when its subtree leaves the paint tree (see `render.tsx`).
|
||||
* Visibility is opacity-based, never unmount: the xterm render loop dies when
|
||||
* its subtree leaves the paint tree (see `render.tsx`).
|
||||
*/
|
||||
|
||||
import type { Accessor, Component, JSX } from "solid-js"
|
||||
import { For, Show, createEffect, createSignal } from "solid-js"
|
||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import type { Accessor, Component } from "solid-js"
|
||||
import { Show, createEffect } from "solid-js"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import { useLanguage } from "../../src/context/language"
|
||||
import { ConstrainDragYAxis } from "../../src/components/chat/TabDnd"
|
||||
import { useTabScroll } from "../../src/utils/tab-scroll"
|
||||
import { setTabWidths } from "../../src/utils/tab-widths"
|
||||
import { createTabFocus } from "../../src/utils/tab-navigation"
|
||||
import { InspectorTabStrip } from "../InspectorTabStrip"
|
||||
import { renderSideTerminalLayer } from "./render"
|
||||
import { SortableTerminalTab } from "./SortableTerminalTab"
|
||||
import type { TerminalStateControls } from "./state"
|
||||
|
||||
/** Only this strip's tabs freeze; the top tab bar keeps its own widths. */
|
||||
const TABLIST = ".am-side-terminal-tablist"
|
||||
|
||||
interface Props {
|
||||
state: TerminalStateControls
|
||||
/** Context the panel currently shows (`state.sideKey`). */
|
||||
@@ -67,65 +45,18 @@ interface Props {
|
||||
export const SideTerminalPanel: Component<Props> = (props) => {
|
||||
const { t } = useLanguage()
|
||||
let panel!: HTMLElement
|
||||
let strip!: HTMLDivElement
|
||||
createEffect(() => {
|
||||
panel.inert = !props.visible()
|
||||
})
|
||||
const [dragging, setDragging] = createSignal<{ id: string; width: number } | undefined>()
|
||||
const sides = () => props.state.sidesForContext(props.contextKey())
|
||||
const ids = () => sides().map((term) => term.id)
|
||||
const active = () => props.state.sideActiveFor(props.contextKey())
|
||||
const pending = () => props.state.pendingSide(props.contextKey())
|
||||
const scroll = useTabScroll(ids, active)
|
||||
// Scoped to `strip` so arrow keys and focus restore never jump to a
|
||||
// tab in the top bar, which uses the same role="tab" markup.
|
||||
const focus = createTabFocus({ ids, select: props.onSelect, root: () => strip })
|
||||
// Only freeze while the pointer is over the strip: the widths must
|
||||
// survive until the pointer leaves, so the remaining X buttons stay
|
||||
// put across repeated closes. Releasing on the next frame would undo
|
||||
// the freeze before it is ever painted (rAF runs before paint).
|
||||
// "Close others" needs none of this: its context menu is portaled, so
|
||||
// the pointer is off the strip, and the survivor spans the strip anyway.
|
||||
const freeze = () => {
|
||||
if (strip.closest(".am-side-terminal-tabs")?.matches(":hover")) setTabWidths(true, document, TABLIST)
|
||||
}
|
||||
const release = () => setTabWidths(false, document, TABLIST)
|
||||
const close = (id: string) => {
|
||||
freeze()
|
||||
const close = (id: string, focus: { restore: () => void }) => {
|
||||
props.onClose(id)
|
||||
// Restore focus inside the strip only while it still owns a tab.
|
||||
// Falling through to `focusPrompt` would pull focus into the chat
|
||||
// composer while the panel is still open on its empty state.
|
||||
if (ids().length > 0) focus.restore()
|
||||
}
|
||||
// Adding a tab shrinks every tab's equal share, so any freeze left
|
||||
// over from a close in the same hover has to go first. `+` lives
|
||||
// inside the strip, so no pointerleave happens between the two
|
||||
// clicks and the surviving tabs would keep their wider pixel widths.
|
||||
const start = () => {
|
||||
release()
|
||||
props.onStart()
|
||||
}
|
||||
const onDragStart = (event: DragEvent) => {
|
||||
const id = event.draggable?.id
|
||||
if (typeof id !== "string") return
|
||||
// Pin the overlay to the tab's width: the overlay container uses
|
||||
// min-width, so a long OSC title would otherwise overflow it and
|
||||
// shift the visual center off the cursor (the "drag offset" bug).
|
||||
const width = event.draggable?.layout.width ?? event.draggable?.node.getBoundingClientRect().width
|
||||
setTabWidths(true, document, TABLIST)
|
||||
setDragging({ id, width })
|
||||
}
|
||||
const onDragEnd = () => {
|
||||
setDragging(undefined)
|
||||
release()
|
||||
}
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
const from = event.draggable?.id
|
||||
const to = event.droppable?.id
|
||||
if (typeof from !== "string" || typeof to !== "string") return
|
||||
props.state.reorderSideDrag(props.contextKey(), from, to)
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={panel}
|
||||
@@ -133,105 +64,64 @@ export const SideTerminalPanel: Component<Props> = (props) => {
|
||||
aria-label={t("agentManager.tab.terminal")}
|
||||
aria-hidden={!props.visible()}
|
||||
>
|
||||
<div
|
||||
class="am-side-terminal-tabs"
|
||||
onPointerLeave={() => {
|
||||
if (!dragging()) release()
|
||||
}}
|
||||
>
|
||||
<DragDropProvider
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
{/* Overflow chrome copied from the top tab bar: the list is the
|
||||
only scrolling element, wrapped by a fade host, so the "+"
|
||||
action stays pinned next to the last tab. */}
|
||||
<div class="am-tab-scroll-area">
|
||||
<div class={`am-tab-fade am-tab-fade-left ${scroll.showLeft() ? "am-tab-fade-visible" : ""}`} />
|
||||
<div class="am-tab-list-wrap">
|
||||
{/* role="tablist" only when tabs exist: axe
|
||||
aria-required-children rejects an empty tablist. */}
|
||||
<div
|
||||
class="am-side-terminal-tablist"
|
||||
ref={(el) => {
|
||||
strip = el
|
||||
scroll.setRef(el)
|
||||
}}
|
||||
role={sides().length > 0 ? "tablist" : undefined}
|
||||
aria-label={sides().length > 0 ? t("agentManager.tab.terminal") : undefined}
|
||||
style={{ "--tab-count": `${sides().length}` } as JSX.CSSProperties}
|
||||
>
|
||||
<SortableProvider ids={ids()}>
|
||||
<For each={sides()}>
|
||||
{(term) => (
|
||||
<SortableTerminalTab
|
||||
id={term.id}
|
||||
label={props.state.title(term.id) ?? term.title}
|
||||
tooltip={props.state.title(term.id) ?? term.title}
|
||||
status={props.state.scriptStatus(term.id)}
|
||||
keybind={active() === term.id ? "" : props.nextKeybind}
|
||||
closeKeybind={props.closeKeybind}
|
||||
active={active() === term.id}
|
||||
focused={props.state.sideFocusedId() === term.id}
|
||||
role="tab"
|
||||
selected={active() === term.id}
|
||||
tabIndex={active() === term.id ? 0 : -1}
|
||||
onKeyDown={(event) => focus.key(term.id, event)}
|
||||
onSelect={() => props.onSelect(term.id)}
|
||||
onMiddleClick={(e: MouseEvent) => {
|
||||
if (e.button !== 1) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
close(term.id)
|
||||
}}
|
||||
onClose={(e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
close(term.id)
|
||||
}}
|
||||
onCloseOthers={() => props.onCloseOthers(term.id)}
|
||||
onStop={(e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
props.onStop(term.id)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div class={`am-tab-fade am-tab-fade-right ${scroll.showRight() ? "am-tab-fade-visible" : ""}`} />
|
||||
</div>
|
||||
{/* Cursor-following clone of the dragged tab (same pattern as
|
||||
the top tab bar). The overlay is what makes the in-list
|
||||
original use solid-dnd's slot-compensated transform, so the
|
||||
dragged tab tracks the cursor without a jump/offset. The
|
||||
original stays dimmed in its slot via .am-tab-dragging. */}
|
||||
<DragOverlay>
|
||||
<Show when={dragging()}>
|
||||
{(tab) => (
|
||||
<div class="am-tab am-tab-overlay" style={{ width: `${tab().width}px` }}>
|
||||
<span class="am-tab-label">{props.state.title(tab().id) ?? t("agentManager.tab.terminal")}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
<div class="am-side-terminal-add">
|
||||
<Tooltip value={t("agentManager.terminal.add")} placement="bottom">
|
||||
<IconButton
|
||||
icon="plus"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={t("agentManager.terminal.add")}
|
||||
onClick={start}
|
||||
<InspectorTabStrip
|
||||
ids={ids}
|
||||
active={active}
|
||||
label={t("agentManager.tab.terminal")}
|
||||
overlay={(id) => props.state.title(id) ?? t("agentManager.tab.terminal")}
|
||||
onSelect={props.onSelect}
|
||||
onReorder={(from, to) => props.state.reorderSideDrag(props.contextKey(), from, to)}
|
||||
renderTab={(id, api) => {
|
||||
const term = sides().find((item) => item.id === id)
|
||||
if (!term) return null
|
||||
return (
|
||||
<SortableTerminalTab
|
||||
id={term.id}
|
||||
label={props.state.title(term.id) ?? term.title}
|
||||
tooltip={props.state.title(term.id) ?? term.title}
|
||||
status={props.state.scriptStatus(term.id)}
|
||||
showKeybind={false}
|
||||
keybind={active() === term.id ? "" : props.nextKeybind}
|
||||
closeKeybind={props.closeKeybind}
|
||||
active={active() === term.id}
|
||||
focused={props.state.sideFocusedId() === term.id}
|
||||
role="tab"
|
||||
selected={active() === term.id}
|
||||
tabIndex={active() === term.id ? 0 : -1}
|
||||
onKeyDown={(event) => api.focus.key(term.id, event)}
|
||||
onSelect={() => props.onSelect(term.id)}
|
||||
onMiddleClick={(event) => {
|
||||
if (event.button !== 1) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
close(term.id, api.focus)
|
||||
}}
|
||||
onClose={() => close(term.id, api.focus)}
|
||||
onCloseOthers={() => props.onCloseOthers(term.id)}
|
||||
onStop={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onStop(term.id)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
action={(api) => (
|
||||
<div class="am-side-terminal-add">
|
||||
<Tooltip value={t("agentManager.terminal.add")} placement="bottom">
|
||||
<IconButton
|
||||
icon="plus"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={t("agentManager.terminal.add")}
|
||||
onClick={() => {
|
||||
api.release()
|
||||
props.onStart()
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{renderSideTerminalLayer({
|
||||
state: props.state,
|
||||
contextKey: props.contextKey,
|
||||
|
||||
@@ -1,191 +1,99 @@
|
||||
/**
|
||||
* Tab chrome for xterm terminals.
|
||||
* Terminal-specific adapter for the shared sortable inspector tab.
|
||||
*
|
||||
* `TerminalTabChrome` is the shared visual tab: console icon, title,
|
||||
* tooltip/keybinding hints, and the X close button — the same
|
||||
* `am-tab*` structure the session tabs use. `SortableTerminalTab`
|
||||
* wraps it with drag-and-drop and a right-click context menu; both the
|
||||
* top tab bar and the side terminal panel render that wrapper, so a
|
||||
* terminal tab behaves identically in either surface.
|
||||
* PTY status determines the icon and whether a Setup tab can be closed. The
|
||||
* tab chrome, drag wrapper, context menu, and close behavior are shared with
|
||||
* subagent tabs.
|
||||
*/
|
||||
|
||||
import { Component, Show, type JSX } from "solid-js"
|
||||
import { Show, type Component } from "solid-js"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
|
||||
import { ContextMenu } from "@kilocode/kilo-ui/context-menu"
|
||||
import { useLanguage } from "../../src/context/language"
|
||||
import { SortableTabContainer } from "../../src/components/chat/TabDnd"
|
||||
import { parseBindingTokens } from "../keybind-tokens"
|
||||
import { SortableClosableTab, type ClosableTabProps } from "../ClosableTab"
|
||||
import { terminalChrome, terminalClosable, terminalStoppable } from "./chrome"
|
||||
import type { ScriptTerminalStatus } from "./state"
|
||||
|
||||
export const TerminalTabChrome: Component<{
|
||||
interface Props extends Omit<ClosableTabProps, "icon" | "onClose" | "trailing"> {
|
||||
label: string
|
||||
tooltip: string
|
||||
status?: ScriptTerminalStatus
|
||||
keybind?: string
|
||||
closeKeybind?: string
|
||||
focused?: boolean
|
||||
active: boolean
|
||||
role?: "tab"
|
||||
selected?: boolean
|
||||
tabIndex?: number
|
||||
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
|
||||
onSelect: () => void
|
||||
onMiddleClick?: (e: MouseEvent) => void
|
||||
onClose: (e: MouseEvent) => void
|
||||
onStop?: (e: MouseEvent) => void
|
||||
}> = (props) => {
|
||||
onClose: () => void
|
||||
onStop?: (event: MouseEvent) => void
|
||||
}
|
||||
|
||||
const StopButton: Component<{ active: boolean; tabIndex: number; onStop?: (event: MouseEvent) => void }> = (props) => {
|
||||
const { t } = useLanguage()
|
||||
const chrome = () => terminalChrome(props.tooltip, props.status)
|
||||
const icon = () => {
|
||||
const kind = chrome().icon
|
||||
if (kind === "success") return "check-small"
|
||||
if (kind === "failure") return "warning"
|
||||
return "console"
|
||||
}
|
||||
return (
|
||||
<div
|
||||
class={`am-tab am-tab-terminal ${props.active ? "am-tab-active" : ""} ${props.focused ? "am-tab-terminal-focused" : ""}`}
|
||||
>
|
||||
<div
|
||||
class="am-tab-target"
|
||||
role={props.role}
|
||||
aria-selected={props.selected}
|
||||
aria-label={chrome().tooltip}
|
||||
tabIndex={props.tabIndex}
|
||||
onClick={props.onSelect}
|
||||
onMouseDown={props.onMiddleClick}
|
||||
onKeyDown={props.onKeyDown}
|
||||
<Show when={props.active && props.onStop}>
|
||||
<TooltipKeybind
|
||||
title={t("agentManager.terminal.stopSetup")}
|
||||
keybind=""
|
||||
placement="top"
|
||||
gutter={8}
|
||||
class="am-tab-close-wrap"
|
||||
openDelay={0}
|
||||
>
|
||||
<TooltipKeybind
|
||||
title={chrome().tooltip}
|
||||
keybind={props.keybind ?? ""}
|
||||
placement="bottom"
|
||||
gutter={8}
|
||||
class="am-tab-tooltip"
|
||||
openDelay={0}
|
||||
>
|
||||
<span class="am-tab-title">
|
||||
<span class="am-tab-icon" data-run-status={chrome().icon}>
|
||||
<Show when={chrome().icon === "spinner"} fallback={<Icon name={icon()} size="small" />}>
|
||||
<Spinner class="am-terminal-tab-spinner" />
|
||||
</Show>
|
||||
</span>
|
||||
<span class="am-tab-label">{props.label}</span>
|
||||
</span>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<Show when={terminalStoppable(props.status) && props.onStop}>
|
||||
<TooltipKeybind
|
||||
title={t("agentManager.terminal.stopSetup")}
|
||||
keybind=""
|
||||
placement="top"
|
||||
gutter={8}
|
||||
class="am-tab-close-wrap"
|
||||
openDelay={0}
|
||||
>
|
||||
<IconButton
|
||||
icon="stop"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={t("agentManager.terminal.stopSetup")}
|
||||
tabIndex={props.active ? 0 : -1}
|
||||
class="am-tab-close"
|
||||
onClick={props.onStop}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
<Show when={terminalClosable(props.status)}>
|
||||
<TooltipKeybind
|
||||
title={t("agentManager.tab.close")}
|
||||
keybind={props.closeKeybind ?? ""}
|
||||
placement="top"
|
||||
gutter={8}
|
||||
class="am-tab-close-wrap"
|
||||
openDelay={0}
|
||||
>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={t("agentManager.tab.closeTab")}
|
||||
tabIndex={props.active ? 0 : -1}
|
||||
class="am-tab-close"
|
||||
onClick={props.onClose}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</div>
|
||||
<IconButton
|
||||
icon="stop"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={t("agentManager.terminal.stopSetup")}
|
||||
tabIndex={props.tabIndex}
|
||||
class="am-tab-close"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onStop?.(event)
|
||||
}}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export const SortableTerminalTab: Component<{
|
||||
id: string
|
||||
label: string
|
||||
tooltip: string
|
||||
status?: ScriptTerminalStatus
|
||||
keybind?: string
|
||||
closeKeybind?: string
|
||||
focused?: boolean
|
||||
active: boolean
|
||||
role?: "tab"
|
||||
selected?: boolean
|
||||
tabIndex?: number
|
||||
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
|
||||
onSelect: () => void
|
||||
onMiddleClick: (e: MouseEvent) => void
|
||||
onClose: (e: MouseEvent) => void
|
||||
onCloseOthers: () => void
|
||||
onStop?: (e: MouseEvent) => void
|
||||
}> = (props) => {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<SortableTabContainer id={props.id}>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger as="div" style={{ display: "contents" }}>
|
||||
<TerminalTabChrome
|
||||
label={props.label}
|
||||
tooltip={props.tooltip}
|
||||
status={props.status}
|
||||
keybind={props.keybind}
|
||||
closeKeybind={props.closeKeybind}
|
||||
focused={props.focused}
|
||||
active={props.active}
|
||||
role={props.role}
|
||||
selected={props.selected}
|
||||
tabIndex={props.tabIndex}
|
||||
onKeyDown={props.onKeyDown}
|
||||
onSelect={props.onSelect}
|
||||
onMiddleClick={props.onMiddleClick}
|
||||
onClose={props.onClose}
|
||||
onStop={props.onStop}
|
||||
/>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content class="am-ctx-menu">
|
||||
<ContextMenu.Item
|
||||
onSelect={() => props.onClose(new MouseEvent("click", { bubbles: true, cancelable: true }) as MouseEvent)}
|
||||
>
|
||||
<Icon name="close" size="small" />
|
||||
<ContextMenu.ItemLabel>{t("agentManager.tab.close")}</ContextMenu.ItemLabel>
|
||||
<Show when={props.closeKeybind}>
|
||||
<span class="am-menu-shortcut">
|
||||
{parseBindingTokens(props.closeKeybind ?? "").map((token) => (
|
||||
<kbd class="am-menu-key">{token}</kbd>
|
||||
))}
|
||||
</span>
|
||||
</Show>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item onSelect={props.onCloseOthers}>
|
||||
<Icon name="close" size="small" />
|
||||
<ContextMenu.ItemLabel>{t("agentManager.tab.closeOthers")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
</SortableTabContainer>
|
||||
)
|
||||
function icon(status: ScriptTerminalStatus | undefined) {
|
||||
const value = terminalChrome("", status).icon
|
||||
if (value === "success") return "check-small" as const
|
||||
if (value === "failure") return "warning" as const
|
||||
if (value === "spinner") return "spinner" as const
|
||||
return "console" as const
|
||||
}
|
||||
|
||||
function iconStatus(status: ScriptTerminalStatus | undefined) {
|
||||
const value = terminalChrome("", status).icon
|
||||
if (value === "success") return "success" as const
|
||||
if (value === "failure") return "failure" as const
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const SortableTerminalTab: Component<
|
||||
Props & {
|
||||
id: string
|
||||
onCloseOthers: () => void
|
||||
}
|
||||
> = (props) => (
|
||||
<SortableClosableTab
|
||||
id={props.id}
|
||||
label={props.label}
|
||||
tooltip={terminalChrome(props.tooltip, props.status).tooltip}
|
||||
icon={() => icon(props.status)}
|
||||
iconStatus={() => iconStatus(props.status)}
|
||||
class="am-tab-terminal"
|
||||
focused={props.focused}
|
||||
active={props.active}
|
||||
closeable={terminalClosable(props.status)}
|
||||
keybind={props.keybind}
|
||||
closeKeybind={props.closeKeybind}
|
||||
role={props.role}
|
||||
selected={props.selected}
|
||||
tabIndex={props.tabIndex}
|
||||
onKeyDown={props.onKeyDown}
|
||||
onSelect={props.onSelect}
|
||||
onMiddleClick={props.onMiddleClick}
|
||||
onClose={props.onClose}
|
||||
onCloseOthers={props.onCloseOthers}
|
||||
trailing={
|
||||
<StopButton active={terminalStoppable(props.status)} tabIndex={props.active ? 0 : -1} onStop={props.onStop} />
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -59,10 +59,7 @@ export function renderTerminalTab(deps: TerminalTabRenderDeps): JSX.Element {
|
||||
onKeyDown={deps.onKeyDown}
|
||||
onSelect={() => deps.onSelect(deps.id)}
|
||||
onMiddleClick={(e: MouseEvent) => deps.onMiddleClick(deps.id, e)}
|
||||
onClose={(e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
deps.onClose(deps.id)
|
||||
}}
|
||||
onClose={() => deps.onClose(deps.id)}
|
||||
onCloseOthers={() => deps.onCloseOthers(deps.id)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -216,6 +216,7 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
|
||||
const { config } = useConfig()
|
||||
const open = createMemo(() => config().terminal_command_display !== "collapsed")
|
||||
const edit = createMemo(() => config().code_edit_display === "expanded")
|
||||
const mcp = createMemo(() => config().mcp_tool_display === "expanded")
|
||||
|
||||
// Throughput toggle lives on the shared DisplayProvider so every
|
||||
// AssistantMessage renders against the same signal without posting its
|
||||
@@ -331,7 +332,7 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
|
||||
part={part}
|
||||
message={props.message as SDKMessage}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
defaultOpen={toolDefaultOpen(part, open(), edit())}
|
||||
defaultOpen={toolDefaultOpen(part, open(), edit(), mcp())}
|
||||
forceOpen={forceOpen()}
|
||||
forceOpenFile={forceOpen() ? props.forceOpenFile : undefined}
|
||||
reasoningAutoCollapse={display.reasoningAutoCollapse()}
|
||||
|
||||
@@ -1234,26 +1234,21 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
const id = pendingRestore()
|
||||
if (!id || session.loading()) return
|
||||
turns().length
|
||||
// Double-rAF: the first frame lets the browser paint the new DOM from
|
||||
// the messagesLoaded batch. The second frame restores scroll position
|
||||
// without forcing a synchronous layout reflow mid-paint.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (pendingRestore() !== id) return
|
||||
const el = scrollEl()
|
||||
if (!el) return
|
||||
const state = getScroll(id)
|
||||
const anchor = resolveAnchor(state, keys())
|
||||
const handle = virtualizer()
|
||||
if (state?.type === "anchor" && anchor && handle) {
|
||||
handle.scrollToIndex(anchor.index, { offset: anchor.offset })
|
||||
autoScroll.pause()
|
||||
maybeLoadOlder()
|
||||
} else {
|
||||
autoScroll.forceScrollToBottom()
|
||||
}
|
||||
setPendingRestore(undefined)
|
||||
})
|
||||
if (pendingRestore() !== id) return
|
||||
const el = scrollEl()
|
||||
if (!el) return
|
||||
const state = getScroll(id)
|
||||
const anchor = resolveAnchor(state, keys())
|
||||
const handle = virtualizer()
|
||||
if (state?.type === "anchor" && anchor && handle) {
|
||||
handle.scrollToIndex(anchor.index, { offset: anchor.offset })
|
||||
autoScroll.pause()
|
||||
maybeLoadOlder()
|
||||
} else {
|
||||
autoScroll.forceScrollToBottom()
|
||||
}
|
||||
setPendingRestore(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ export const PermissionDock: Component<{
|
||||
}
|
||||
const text = (rule: string) => (command() ? label(rule) : describeRule(props.request.toolName, rule, language.t))
|
||||
const external = () => props.request.toolName === "external_directory"
|
||||
const sandboxEscalation = () => props.request.toolName === "sandbox_escalation"
|
||||
const cmdDescription = () => {
|
||||
const val = props.request.args?.description
|
||||
return typeof val === "string" && val.length > 0 ? val : undefined
|
||||
@@ -129,6 +130,7 @@ export const PermissionDock: Component<{
|
||||
}
|
||||
|
||||
const title = () => {
|
||||
if (sandboxEscalation()) return language.t("notification.permission.titleSandboxEscalation")
|
||||
const skill = props.request.args?.skill
|
||||
if (skillShell() && typeof skill === "string" && skill.length > 0)
|
||||
// Escape the untrusted skill name so bidi/control chars can't reorder the header text.
|
||||
|
||||
@@ -373,7 +373,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
createEffect(
|
||||
on(draftKey, (key, prev) => {
|
||||
if (prev !== undefined && prev !== key) {
|
||||
saveDraft(prev, untrack(text), untrack(reviewComments), untrack(imageAttach.images))
|
||||
const val = untrack(text)
|
||||
const comments = untrack(reviewComments)
|
||||
const imgs = untrack(imageAttach.images)
|
||||
if (val || comments.length > 0 || imgs.length > 0 || drafts.has(prev)) {
|
||||
saveDraft(prev, val, comments, imgs)
|
||||
}
|
||||
}
|
||||
const draft = drafts.get(key) ?? ""
|
||||
const pending = reviewDrafts.get(key) ?? []
|
||||
@@ -406,14 +411,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
createEffect(() => {
|
||||
const msgs = session.userMessages()
|
||||
if (msgs.length === 0) return
|
||||
const texts = msgs.map((m) => {
|
||||
const parts = session.getParts(m.id)
|
||||
return parts
|
||||
.filter((part): part is TextPart => part.type === "text")
|
||||
.map((part) => partReview(part.metadata, part.text)?.body ?? part.text.replace(REVIEW_PREFIX, ""))
|
||||
.join("")
|
||||
})
|
||||
history.seed(texts)
|
||||
const timer = setTimeout(() => {
|
||||
const texts = msgs.map((m) => {
|
||||
const parts = session.getParts(m.id)
|
||||
return parts
|
||||
.filter((part): part is TextPart => part.type === "text")
|
||||
.map((part) => partReview(part.metadata, part.text)?.body ?? part.text.replace(REVIEW_PREFIX, ""))
|
||||
.join("")
|
||||
})
|
||||
history.seed(texts)
|
||||
}, 100)
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
})
|
||||
|
||||
// Focus textarea when any part of the app requests it
|
||||
|
||||
@@ -8,6 +8,7 @@ export const SessionTabMenu: ParentComponent<{
|
||||
onFork?: () => void
|
||||
onClose: () => void
|
||||
onCloseOthers?: () => void
|
||||
closeable?: boolean
|
||||
closeShortcut?: JSX.Element
|
||||
}> = (props) => {
|
||||
const { t } = useLanguage()
|
||||
@@ -23,13 +24,17 @@ export const SessionTabMenu: ParentComponent<{
|
||||
<Icon name="fork" size="small" />
|
||||
<ContextMenu.ItemLabel>{t("agentManager.tab.forkSession")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<Show when={props.closeable !== false || props.onCloseOthers}>
|
||||
<ContextMenu.Separator />
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={props.closeable !== false}>
|
||||
<ContextMenu.Item onSelect={props.onClose}>
|
||||
<Icon name="close" size="small" />
|
||||
<ContextMenu.ItemLabel>{t("agentManager.tab.close")}</ContextMenu.ItemLabel>
|
||||
{props.closeShortcut}
|
||||
</ContextMenu.Item>
|
||||
</Show>
|
||||
<ContextMenu.Item onSelect={props.onClose}>
|
||||
<Icon name="close" size="small" />
|
||||
<ContextMenu.ItemLabel>{t("agentManager.tab.close")}</ContextMenu.ItemLabel>
|
||||
{props.closeShortcut}
|
||||
</ContextMenu.Item>
|
||||
<Show when={props.onCloseOthers}>
|
||||
<ContextMenu.Item onSelect={() => props.onCloseOthers?.()}>
|
||||
<Icon name="close" size="small" />
|
||||
|
||||
@@ -42,7 +42,8 @@ export const TaskHeader: Component<TaskHeaderProps> = (props) => {
|
||||
const busy = createMemo(() => session.status() === "busy")
|
||||
const canCompact = createMemo(() => !busy() && session.visibleMessages().length > 0 && !!session.selected())
|
||||
|
||||
const fmt = (n: number) => new Intl.NumberFormat(language.locale(), { style: "currency", currency: "USD" }).format(n)
|
||||
const money = createMemo(() => new Intl.NumberFormat(language.locale(), { style: "currency", currency: "USD" }))
|
||||
const fmt = (n: number) => money().format(n)
|
||||
|
||||
const breakdown = () => session.costBreakdown()
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useI18n } from "@kilocode/kilo-ui/context/i18n"
|
||||
import { createAutoScroll } from "@kilocode/kilo-ui/hooks"
|
||||
import { useSession } from "../../context/session"
|
||||
import { useVSCode } from "../../context/vscode"
|
||||
import { useWorktreeMode } from "../../context/worktree-mode"
|
||||
import { childID } from "../../context/session-utils"
|
||||
import { taskResult, taskRunning, taskVisible } from "./task-tool-state"
|
||||
|
||||
@@ -26,6 +27,7 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
|
||||
const language = useLanguage()
|
||||
const session = useSession()
|
||||
const vscode = useVSCode()
|
||||
const worktree = useWorktreeMode()
|
||||
|
||||
const childSessionId = () =>
|
||||
childID({
|
||||
@@ -50,11 +52,18 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
|
||||
}),
|
||||
)
|
||||
|
||||
let synced: string | undefined
|
||||
createEffect(() => {
|
||||
const id = taskVisible(open(), childSessionId())
|
||||
if (synced === id) return
|
||||
if (synced) session.unsyncSession(synced)
|
||||
synced = id
|
||||
if (!id) return
|
||||
session.syncSession(id)
|
||||
})
|
||||
onCleanup(() => {
|
||||
if (synced) session.unsyncSession(synced)
|
||||
})
|
||||
|
||||
const title = createMemo(() => i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool }))
|
||||
|
||||
@@ -115,7 +124,16 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
|
||||
e.stopPropagation()
|
||||
const id = childSessionId()
|
||||
if (!id) return
|
||||
vscode.postMessage({ type: "openSubAgentViewer", sessionID: id, title: description() })
|
||||
const title = description()
|
||||
if (worktree) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("agentManager.openSubagent", {
|
||||
detail: { sessionID: id, title, parentSessionID: session.currentSessionID() },
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
vscode.postMessage({ type: "openSubAgentViewer", sessionID: id, title })
|
||||
}
|
||||
|
||||
const trigger = () => (
|
||||
@@ -138,7 +156,7 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
|
||||
icon="square-arrow-top-right"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label="Open sub-agent in tab"
|
||||
aria-label={worktree ? "Open sub-agent in panel" : "Open sub-agent in tab"}
|
||||
onClick={openInTab}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
@@ -35,7 +35,7 @@ export const TaskUsage: Component<TaskUsageProps> = (props) => {
|
||||
if (value > 0 && value < 0.000001) return "<$0.000001"
|
||||
return money().format(value)
|
||||
}
|
||||
const Summary = () => (
|
||||
const renderSummary = () => (
|
||||
<>
|
||||
<span class="task-header-tokens-label">Tokens</span>
|
||||
<Show when={props.tokens.input > 0}>
|
||||
@@ -60,19 +60,10 @@ export const TaskUsage: Component<TaskUsageProps> = (props) => {
|
||||
)
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={props.usage?.models.length}
|
||||
fallback={
|
||||
<div class="task-header-tokens">
|
||||
<Summary />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={props.usage?.models.length} fallback={<div class="task-header-tokens">{renderSummary()}</div>}>
|
||||
<Collapsible variant="ghost" class="task-header-usage tool-collapsible" defaultOpen={props.defaultOpen}>
|
||||
<Collapsible.Trigger class="task-header-usage-trigger">
|
||||
<span class="task-header-tokens">
|
||||
<Summary />
|
||||
</span>
|
||||
<span class="task-header-tokens">{renderSummary()}</span>
|
||||
<Collapsible.Arrow />
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
|
||||
@@ -2,11 +2,32 @@ import type { Part as SDKPart, ToolPart } from "@kilocode/sdk/v2"
|
||||
|
||||
const EDIT_TOOLS = new Set(["edit", "write", "apply_patch"])
|
||||
const TERMINAL_TOOLS = new Set(["bash", "background_process"])
|
||||
const BUILTIN_NON_MCP_TOOLS = new Set([
|
||||
"read",
|
||||
"list",
|
||||
"glob",
|
||||
"grep",
|
||||
"webfetch",
|
||||
"websearch",
|
||||
"chart",
|
||||
"codesearch",
|
||||
"semantic_search",
|
||||
"task",
|
||||
"todowrite",
|
||||
"todoread",
|
||||
"question",
|
||||
"suggest",
|
||||
"skill",
|
||||
"plan_enter",
|
||||
"plan_exit",
|
||||
])
|
||||
|
||||
export function toolDefaultOpen(part: SDKPart, terminal: boolean, edit: boolean) {
|
||||
export function toolDefaultOpen(part: SDKPart, terminal: boolean, edit: boolean, mcp?: boolean) {
|
||||
if (part.type !== "tool") return undefined
|
||||
const tool = (part as unknown as ToolPart).tool
|
||||
if (TERMINAL_TOOLS.has(tool)) return terminal
|
||||
if (EDIT_TOOLS.has(tool)) return edit
|
||||
if (BUILTIN_NON_MCP_TOOLS.has(tool)) return undefined
|
||||
if (mcp !== undefined) return mcp
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ const HistoryView: Component<HistoryViewProps> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const session = useSession()
|
||||
const tabs = useLocalTabs()
|
||||
const [tab, setTab] = createSignal<Source>("local")
|
||||
const worktreeIds = () => props.worktreeSessionIds?.()
|
||||
const [tab, setTab] = createSignal<Source>(worktreeIds() ? "worktree" : "local")
|
||||
let local: HTMLButtonElement | undefined
|
||||
let cloud: HTMLButtonElement | undefined
|
||||
let worktree: HTMLButtonElement | undefined
|
||||
@@ -37,8 +38,6 @@ const HistoryView: Component<HistoryViewProps> = (props) => {
|
||||
let cloudPanel: HTMLDivElement | undefined
|
||||
let worktreePanel: HTMLDivElement | undefined
|
||||
|
||||
const worktreeIds = () => props.worktreeSessionIds?.()
|
||||
|
||||
createEffect(() => {
|
||||
if (tab() === "worktree" && !worktreeIds()) setTab("local")
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Switch } from "@kilocode/kilo-ui/switch"
|
||||
import { useConfig } from "../../context/config"
|
||||
import { useDisplay } from "../../context/display"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import type { CodeEditDisplay, TerminalCommandDisplay } from "../../types/messages"
|
||||
import type { CodeEditDisplay, McpToolDisplay, TerminalCommandDisplay } from "../../types/messages"
|
||||
import SettingsRow from "./SettingsRow"
|
||||
|
||||
interface LayoutOption {
|
||||
@@ -24,6 +24,11 @@ const CODE_EDIT_OPTIONS: LayoutOption[] = [
|
||||
{ value: "collapsed", labelKey: "settings.display.codeEdit.collapsed" },
|
||||
]
|
||||
|
||||
const MCP_OPTIONS: LayoutOption[] = [
|
||||
{ value: "expanded", labelKey: "settings.display.mcpTool.expanded" },
|
||||
{ value: "collapsed", labelKey: "settings.display.mcpTool.collapsed" },
|
||||
]
|
||||
|
||||
const DisplayTab: Component = () => {
|
||||
const { config, updateConfig, settings, updateSetting } = useConfig()
|
||||
const display = useDisplay()
|
||||
@@ -96,7 +101,7 @@ const DisplayTab: Component = () => {
|
||||
description={language.t("settings.display.tokenThroughput.description")}
|
||||
>
|
||||
<Switch
|
||||
checked={Boolean(settings()["showTokenThroughput"] ?? false)}
|
||||
checked={Boolean(settings()["showTokenThroughput"] ?? true)}
|
||||
onChange={(checked: boolean) => updateSetting("showTokenThroughput", checked)}
|
||||
hideLabel
|
||||
>
|
||||
@@ -141,7 +146,6 @@ const DisplayTab: Component = () => {
|
||||
<SettingsRow
|
||||
title={language.t("settings.display.codeEdit.title")}
|
||||
description={language.t("settings.display.codeEdit.description")}
|
||||
last
|
||||
>
|
||||
<Select
|
||||
options={CODE_EDIT_OPTIONS}
|
||||
@@ -159,6 +163,28 @@ const DisplayTab: Component = () => {
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.display.mcpTool.title")}
|
||||
description={language.t("settings.display.mcpTool.description")}
|
||||
last
|
||||
>
|
||||
<Select
|
||||
options={MCP_OPTIONS}
|
||||
current={MCP_OPTIONS.find((o) => o.value === (config().mcp_tool_display ?? "collapsed"))}
|
||||
value={(o) => o.value}
|
||||
label={(o) => language.t(o.labelKey)}
|
||||
onSelect={(o) => {
|
||||
if (!o) return
|
||||
const next = o.value as McpToolDisplay
|
||||
if (next === (config().mcp_tool_display ?? "collapsed")) return
|
||||
updateConfig({ mcp_tool_display: next })
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -38,6 +38,7 @@ export const KNOWN_KEYS: ReadonlyArray<string> = [
|
||||
"auto_collapse_reasoning",
|
||||
"terminal_command_display",
|
||||
"code_edit_display",
|
||||
"mcp_tool_display",
|
||||
"hide_prompt_training_models",
|
||||
"sandbox",
|
||||
"indexing",
|
||||
|
||||
@@ -776,7 +776,6 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
buildTriggerLabel(
|
||||
activeModel()?.name,
|
||||
activeModel()?.providerID,
|
||||
activeModel()?.providerName,
|
||||
props.value,
|
||||
props.allowClear ?? false,
|
||||
props.clearLabel ?? "",
|
||||
@@ -1011,7 +1010,6 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
const hovered = () => isSelected(row.key)
|
||||
const preActive = () => isPreActive(row.key)
|
||||
const starred = () => favoriteKeys().has(modelKey(model.providerID, model.id))
|
||||
const showProvider = () => row.kind === "favorite" || hasSearch()
|
||||
const showSelect = () => expanded() && preActive() && !isActive(model)
|
||||
const starLabel = () =>
|
||||
`${starred() ? language.t("model.favorite.remove") : language.t("model.favorite.add")}: ${sanitizeName(model.name)}`
|
||||
@@ -1082,9 +1080,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={showProvider()}>
|
||||
<span class="model-selector-item-provider-tag">{model.providerName}</span>
|
||||
</Show>
|
||||
<span class="model-selector-item-provider-tag">{model.providerName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={session && props.favorites !== false}>
|
||||
|
||||
@@ -216,7 +216,6 @@ export function stripSubProviderPrefix(name: string): string {
|
||||
export function buildTriggerLabel(
|
||||
resolvedName: string | undefined,
|
||||
providerID: string | undefined,
|
||||
providerName: string | undefined,
|
||||
raw: ModelSelection | null,
|
||||
allowClear: boolean,
|
||||
clearLabel: string,
|
||||
@@ -225,7 +224,6 @@ export function buildTriggerLabel(
|
||||
): string {
|
||||
if (resolvedName) {
|
||||
if (providerID === KILO_GATEWAY_ID) return stripSubProviderPrefix(resolvedName)
|
||||
if (providerName) return `${providerName} / ${resolvedName}`
|
||||
return resolvedName
|
||||
}
|
||||
if (raw?.providerID && raw?.modelID) {
|
||||
|
||||
@@ -35,7 +35,7 @@ export const DisplayProvider: ParentComponent = (props) => {
|
||||
const vscode = useVSCode()
|
||||
const reasoningAutoCollapse = createMemo(() => config().auto_collapse_reasoning ?? false)
|
||||
const [fontSize, setFontSizeSignal] = createSignal(readFontSize())
|
||||
const [throughputVisible, setThroughputVisible] = createSignal(false)
|
||||
const [throughputVisible, setThroughputVisible] = createSignal(true)
|
||||
const [autoApprovalReasonVisible, setAutoApprovalReasonVisible] = createSignal(true)
|
||||
|
||||
// Request both toggles once on mount; the extension posts back
|
||||
|
||||
@@ -31,7 +31,17 @@ export function resolvePromptAgent(input: {
|
||||
selections: Record<string, string>
|
||||
pending: string | null
|
||||
}) {
|
||||
if (input.sessionID) return input.selections[input.sessionID]
|
||||
if (input.sessionID) {
|
||||
const sel = input.selections[input.sessionID]
|
||||
if (sel) return sel
|
||||
// Only fall back to the pending selection for draft scopes, not real server
|
||||
// sessions. A server session with no stored selection must keep its own agent
|
||||
// rather than be flipped to the stale/default pending agent.
|
||||
if (!input.sessionID.startsWith("ses_")) {
|
||||
return input.pending ?? undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return input.pending ?? undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,15 @@ export function sameParts(local: Part[] = [], snapshot: Part[] = []): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
export function mergeOptimisticPart(current: Part[], ids: ReadonlySet<string>, part: Part) {
|
||||
const index = current.findIndex((item) => ids.has(item.id) && item.type === part.type)
|
||||
if (index < 0) return { parts: [...current, part] }
|
||||
const old = current[index]!
|
||||
const next = current.slice()
|
||||
next[index] = part
|
||||
return { parts: next, replaced: old.id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile snapshots may be older than in-flight streaming deltas. Preserve
|
||||
* only appended streamed tail parts and open prefix extensions while still
|
||||
|
||||
@@ -200,9 +200,9 @@ export function activeUserMessageID(
|
||||
parts?: (msg: Message) => Message["parts"],
|
||||
submitting?: boolean,
|
||||
) {
|
||||
if (status.type === "idle" && !submitting) return undefined
|
||||
const id = active(messages, status, parts)
|
||||
if (id) return id
|
||||
if (status.type === "idle" && !submitting) return undefined
|
||||
return pending(messages, parts)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { reconcile } from "solid-js/store"
|
||||
import type { Message, MessageLoadMode, Part, ToolPart } from "../types/messages"
|
||||
|
||||
export const SNAPSHOT_PROGRESS_TEXT = "Initializing snapshot..."
|
||||
|
||||
export type MessageMutation = Exclude<MessageLoadMode, "focus"> | "append" | "update"
|
||||
|
||||
export interface MessagePageState {
|
||||
loadingInitial: boolean
|
||||
loadingOlder: boolean
|
||||
before?: string
|
||||
hasMore: boolean
|
||||
lastMutation?: MessageMutation
|
||||
}
|
||||
|
||||
export const emptyPageState: MessagePageState = {
|
||||
loadingInitial: false,
|
||||
loadingOlder: false,
|
||||
hasMore: false,
|
||||
}
|
||||
|
||||
/** Remove ids from a Set immutably, returning the original when nothing changed. */
|
||||
export function dropSet(prev: Set<string>, ids: Iterable<string>): Set<string> {
|
||||
const next = new Set(prev)
|
||||
@@ -8,7 +26,13 @@ export function dropSet(prev: Set<string>, ids: Iterable<string>): Set<string> {
|
||||
return next.size === prev.size ? prev : next
|
||||
}
|
||||
|
||||
export const SNAPSHOT_PROGRESS_TEXT = "Initializing snapshot..."
|
||||
export function messageParts(messages: Message[]): Record<string, Part[]> {
|
||||
const parts: Record<string, Part[]> = {}
|
||||
for (const msg of messages) {
|
||||
if (msg.parts && msg.parts.length > 0) parts[msg.id] = msg.parts
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
type SnapshotPart = {
|
||||
type?: string
|
||||
@@ -481,11 +505,3 @@ export function mergeMessages(current: Message[], incoming: Message[], mode: Exc
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function messageParts(messages: Message[]): Record<string, Part[]> {
|
||||
const parts: Record<string, Part[]> = {}
|
||||
for (const msg of messages) {
|
||||
if (msg.parts && msg.parts.length > 0) parts[msg.id] = msg.parts
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user