Merge branch 'main' into fix/cursor-pointer-clickable-elements

This commit is contained in:
Mark IJbema
2026-03-05 15:17:40 +01:00
committed by GitHub
74 changed files with 811 additions and 636 deletions
+1
View File
@@ -7,6 +7,7 @@ on:
jobs:
sync:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
+1
View File
@@ -17,6 +17,7 @@ permissions:
jobs:
close-stale-prs:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-2vcpu-ubuntu-2404 # kilocode_change
timeout-minutes: 15
steps:
+1 -1
View File
@@ -13,7 +13,7 @@ permissions:
jobs:
close-non-compliant:
if: false # kilocode_change - disabled: not needed in kilocode repo
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
runs-on: ubuntu-latest
steps:
- name: Close non-compliant issues and PRs after 2 hours
+1
View File
@@ -14,6 +14,7 @@ on:
jobs:
link-checker:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+1
View File
@@ -7,6 +7,7 @@ on:
jobs:
generate:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
+1
View File
@@ -16,6 +16,7 @@ permissions:
jobs:
nix-eval:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
steps:
+2 -1
View File
@@ -21,6 +21,7 @@ jobs:
# Native runners required: bun install cross-compilation flags (--os/--cpu)
# do not produce byte-identical node_modules as native installs.
compute-hash:
if: github.repository == 'Kilo-Org/kilocode'
strategy:
fail-fast: false
matrix:
@@ -76,7 +77,7 @@ jobs:
update-hashes:
needs: compute-hash
if: github.event_name != 'pull_request'
if: github.repository == 'Kilo-Org/kilocode' && github.event_name != 'pull_request'
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
+3 -3
View File
@@ -7,7 +7,7 @@ on:
jobs:
# kilocode_change start
check-author:
if: false # kilocode_change - disabled: not needed in kilocode repo
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
uses: ./.github/workflows/check-org-member.yml
with:
username: ${{ github.event.pull_request.user.login }}
@@ -17,7 +17,7 @@ jobs:
check-duplicates:
needs: check-author
if: false # kilocode_change - disabled: not needed in kilocode repo
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
# kilocode_change end
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
@@ -89,7 +89,7 @@ jobs:
fi
add-contributor-label:
if: false # kilocode_change - disabled: not needed in kilocode repo
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
runs-on: blacksmith-2vcpu-ubuntu-2404 # kilocode_change
permissions:
pull-requests: write
+3 -3
View File
@@ -7,7 +7,7 @@ on:
jobs:
# kilocode_change start
check-author:
if: false # kilocode_change - disabled: not needed in kilocode repo
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
uses: ./.github/workflows/check-org-member.yml
with:
username: ${{ github.event.pull_request.user.login }}
@@ -17,7 +17,7 @@ jobs:
check-standards:
needs: check-author
if: false # kilocode_change - disabled: not needed in kilocode repo
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
# kilocode_change end
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
@@ -141,7 +141,7 @@ jobs:
console.log('PR meets all standards');
check-compliance:
if: false # kilocode_change - disabled: not needed in kilocode repo
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
runs-on: ubuntu-latest
permissions:
contents: read
+1
View File
@@ -11,6 +11,7 @@ env:
jobs:
stale:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
issues: write
+1
View File
@@ -6,6 +6,7 @@ on:
jobs:
triage:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
+32 -11
View File
@@ -2,18 +2,33 @@ name: Visual Regression Tests
on:
pull_request:
paths:
- "packages/kilo-ui/**"
- "packages/ui/**"
- "packages/util/**"
- "packages/sdk/js/**"
- "packages/kilo-vscode/webview-ui/**"
- "packages/kilo-vscode/.storybook/**"
- "packages/kilo-vscode/tests/visual-regression*"
- ".github/workflows/visual-regression.yml"
types: [opened, synchronize, reopened]
jobs:
check-paths:
name: Check changed paths
runs-on: ubuntu-latest
outputs:
matched: ${{ steps.filter.outputs.matched }}
steps:
- uses: actions/checkout@v4
- uses: Kilo-Org/paths-filter@master
id: filter
with:
filters: |
matched:
- "packages/kilo-ui/**"
- "packages/ui/**"
- "packages/util/**"
- "packages/sdk/js/**"
- "packages/kilo-vscode/webview-ui/**"
- "packages/kilo-vscode/.storybook/**"
- "packages/kilo-vscode/tests/visual-regression*"
- ".github/workflows/visual-regression.yml"
visual-regression:
needs: check-paths
if: needs.check-paths.outputs.matched == 'true'
name: Visual Regression (kilo-ui)
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
@@ -23,7 +38,7 @@ jobs:
uses: actions/checkout@v4
with:
lfs: true
token: ${{ secrets.GITHUB_TOKEN }}
token: ${{ secrets.BOT_PAT }}
ref: ${{ github.head_ref }}
- name: Setup Bun
@@ -77,6 +92,8 @@ jobs:
- name: Commit and push new baselines (if any)
id: commit-baselines
env:
GH_TOKEN: ${{ secrets.BOT_PAT }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
@@ -106,6 +123,8 @@ jobs:
retention-days: 7
visual-regression-vscode:
needs: check-paths
if: needs.check-paths.outputs.matched == 'true'
name: Visual Regression (kilo-vscode webview)
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
@@ -115,7 +134,7 @@ jobs:
uses: actions/checkout@v4
with:
lfs: true
token: ${{ secrets.GITHUB_TOKEN }}
token: ${{ secrets.BOT_PAT }}
ref: ${{ github.head_ref }}
- name: Setup Bun
@@ -169,6 +188,8 @@ jobs:
- name: Commit and push new baselines (if any)
id: commit-baselines-vscode
env:
GH_TOKEN: ${{ secrets.BOT_PAT }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
@@ -19,6 +19,7 @@ type Validator = any
type Resolver = any
type Errors = any
type Auth = any
type ModelCache = { clear: (providerID: string) => void }
type Z = any
interface KiloRoutesDeps extends ImportDeps {
@@ -28,6 +29,7 @@ interface KiloRoutesDeps extends ImportDeps {
resolver: Resolver
errors: Errors
Auth: Auth
ModelCache: ModelCache
z: Z
}
@@ -72,6 +74,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
Bus,
SessionCreatedEvent,
Identifier,
ModelCache,
} = deps
const Organization = z.object({
@@ -200,6 +203,8 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
...(organizationId && { accountId: organizationId }),
})
ModelCache.clear("kilo")
return c.json(true)
},
)
@@ -59,7 +59,7 @@ html[data-theme="kilo-vscode"] [data-component="tool-trigger"] {
}
/* Card styling for tool calls in the VS Code sidebar theme */
html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"] {
html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-type="tool"] {
border-radius: var(--radius-md);
border: 1px solid var(--border-weak-base, var(--vscode-panel-border));
overflow: hidden;
+31 -2
View File
@@ -1612,6 +1612,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
console.log("[Kilo New] KiloProvider: 🔐 Login successful")
await this.client.global.dispose().catch((e: unknown) =>
console.warn("[Kilo New] KiloProvider: global.dispose() after login failed:", e),
)
// Step 4: Fetch profile and push to webview
const { data: profileData } = await this.client.kilo.profile(undefined, { throwOnError: true })
this.postMessage({ type: "profileData", data: profileData })
@@ -1657,6 +1661,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return
}
await this.client.global.dispose().catch((e: unknown) =>
console.warn("[Kilo New] KiloProvider: global.dispose() after org switch failed:", e),
)
// Org switch succeeded — refresh profile and providers independently (best-effort)
try {
const profileResult = await sdkClient.kilo.profile()
@@ -1709,6 +1717,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
type: "profileData",
data: null,
})
await this.client.global.dispose().catch((e: unknown) =>
console.warn("[Kilo New] KiloProvider: global.dispose() after logout failed:", e),
)
} catch (error) {
console.error("[Kilo New] KiloProvider: ❌ Logout failed:", error)
this.postMessage({
@@ -1716,6 +1729,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
message: getErrorMessage(error) || "Failed to logout",
})
}
}
/**
@@ -1802,6 +1817,21 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return this.connectionService.resolveEventSessionId(event)
}
/**
* Re-fetch all server-side state after an auth change (login/logout/org switch).
* After instance.dispose() clears the server cache, the next request to each
* endpoint will re-initialize with the current auth state.
* This mirrors the TUI's sync.bootstrap() pattern.
*/
private async reloadAfterAuthChange(): Promise<void> {
await Promise.all([
this.fetchAndSendProviders(),
this.fetchAndSendAgents(),
this.fetchAndSendConfig(),
this.fetchAndSendNotifications(),
])
}
/**
* Handle SSE events from the CLI backend.
* Filters events by project ID and tracked session IDs so each webview only sees its own sessions.
@@ -1827,8 +1857,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Refresh provider and agent lists when the server signals a state disposal
if (event.type === "server.instance.disposed" || event.type === "global.disposed") {
void this.fetchAndSendProviders()
void this.fetchAndSendAgents()
void this.reloadAfterAuthChange();
return
}
@@ -67,11 +67,45 @@ export class WorktreeManager {
this.log = log
}
// ---------------------------------------------------------------------------
// Per-project git operation mutex
// ---------------------------------------------------------------------------
// Serializes git-writing operations per repository root so concurrent
// callers (e.g. multi-version worktree creation) don't hit index.lock
// conflicts. Operations on different repositories proceed in parallel.
private static locks = new Map<string, Promise<void>>()
private withGitLock<T>(fn: () => Promise<T>): Promise<T> {
const key = this.root
const prev = WorktreeManager.locks.get(key) ?? Promise.resolve()
const result = prev.then(fn)
const barrier = result.then(
() => {},
() => {},
)
WorktreeManager.locks.set(key, barrier)
return result
}
// ---------------------------------------------------------------------------
// Public API (acquires git lock)
// ---------------------------------------------------------------------------
async createWorktree(params: {
prompt?: string
existingBranch?: string
baseBranch?: string
branchName?: string
}): Promise<CreateWorktreeResult> {
return this.withGitLock(() => this.createWorktreeImpl(params))
}
private async createWorktreeImpl(params: {
prompt?: string
existingBranch?: string
baseBranch?: string
branchName?: string
}): Promise<CreateWorktreeResult> {
const repo = await this.git.checkIsRepo()
if (!repo)
@@ -108,7 +142,7 @@ export class WorktreeManager {
if (fs.existsSync(worktreePath)) {
this.log(`Worktree directory exists, cleaning up before re-creation: ${worktreePath}`)
await this.removeWorktree(worktreePath)
await this.removeWorktreeImpl(worktreePath)
}
try {
@@ -154,6 +188,10 @@ export class WorktreeManager {
* that git doesn't know about.
*/
async removeWorktree(worktreePath: string): Promise<void> {
return this.withGitLock(() => this.removeWorktreeImpl(worktreePath))
}
private async removeWorktreeImpl(worktreePath: string): Promise<void> {
const clean = await this.git.raw(["worktree", "remove", worktreePath]).then(
() => true,
() => false,
@@ -440,6 +478,10 @@ export class WorktreeManager {
}
async createFromPR(url: string): Promise<CreateWorktreeResult> {
return this.withGitLock(() => this.createFromPRImpl(url))
}
private async createFromPRImpl(url: string): Promise<CreateWorktreeResult> {
const parsed = parsePRUrl(url)
if (!parsed) throw new Error("Invalid PR URL. Expected: https://github.com/owner/repo/pull/123")
@@ -462,7 +504,7 @@ export class WorktreeManager {
await this.git.raw(["branch", branch, `${forkOwner}/${info.headRefName}`])
}
return this.createWorktree({ existingBranch: branch })
return this.createWorktreeImpl({ existingBranch: branch })
}
private async fetchPRInfo(parsed: { owner: string; repo: string; number: number }): Promise<PRInfo> {
@@ -83,12 +83,7 @@ export class FimPromptBuilder {
response += text
}
logtime("prep fim")
const usageInfo = await model.generateFimResponse(
formattedPrefix,
prunedSuffix,
onChunk,
signal,
)
const usageInfo = await model.generateFimResponse(formattedPrefix, prunedSuffix, onChunk, signal)
logtime("fim network")
console.log("[FIM] response:", response)
@@ -27,201 +27,199 @@ export type SSEStateHandler = (state: "connecting" | "connected" | "disconnected
* straightforward improvement.
*/
export class SdkSSEAdapter {
private readonly handlers = new Set<SSEEventHandler>()
private readonly errorHandlers = new Set<SSEErrorHandler>()
private readonly stateHandlers = new Set<SSEStateHandler>()
private readonly handlers = new Set<SSEEventHandler>()
private readonly errorHandlers = new Set<SSEErrorHandler>()
private readonly stateHandlers = new Set<SSEStateHandler>()
private abortController: AbortController | null = null
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null
private abortController: AbortController | null = null
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null
private static readonly HEARTBEAT_TIMEOUT_MS = 90_000
private static readonly RECONNECT_DELAY_MS = 250
private static readonly HEARTBEAT_TIMEOUT_MS = 90_000
private static readonly RECONNECT_DELAY_MS = 250
constructor(private readonly client: KiloClient) {}
constructor(private readonly client: KiloClient) {}
// ── Lifecycle ──────────────────────────────────────────────────────
// ── Lifecycle ──────────────────────────────────────────────────────
/**
* Start consuming the global SSE stream.
* Calling `connect()` while already connected is a no-op.
*/
connect(): void {
if (this.abortController) {
console.log("[Kilo New] SSE: ⚠️ Already connected, skipping")
return
}
/**
* Start consuming the global SSE stream.
* Calling `connect()` while already connected is a no-op.
*/
connect(): void {
if (this.abortController) {
console.log("[Kilo New] SSE: ⚠️ Already connected, skipping")
return
}
console.log("[Kilo New] SSE: 🔌 connect() called")
this.abortController = new AbortController()
console.log('[Kilo New] SSE: 🔄 Setting state to "connecting"')
this.notifyState("connecting")
void this.consumeLoop(this.abortController.signal).catch((err) => {
console.error("[Kilo New] SSE: Unhandled error in consumeLoop:", err)
this.notifyError(err instanceof Error ? err : new Error(String(err)))
})
}
console.log("[Kilo New] SSE: 🔌 connect() called")
this.abortController = new AbortController()
console.log('[Kilo New] SSE: 🔄 Setting state to "connecting"')
this.notifyState("connecting")
void this.consumeLoop(this.abortController.signal).catch((err) => {
console.error("[Kilo New] SSE: Unhandled error in consumeLoop:", err)
this.notifyError(err instanceof Error ? err : new Error(String(err)))
})
}
/**
* Stop consuming the SSE stream and abort any in-flight request.
*/
disconnect(): void {
console.log("[Kilo New] SSE: 🔌 disconnect() called")
this.abortController?.abort()
this.abortController = null
this.clearHeartbeat()
}
/**
* Stop consuming the SSE stream and abort any in-flight request.
*/
disconnect(): void {
console.log("[Kilo New] SSE: 🔌 disconnect() called")
this.abortController?.abort()
this.abortController = null
this.clearHeartbeat()
}
/**
* Disconnect and clear all registered handlers.
*/
dispose(): void {
this.disconnect()
this.handlers.clear()
this.errorHandlers.clear()
this.stateHandlers.clear()
}
/**
* Disconnect and clear all registered handlers.
*/
dispose(): void {
this.disconnect()
this.handlers.clear()
this.errorHandlers.clear()
this.stateHandlers.clear()
}
// ── Pub/sub ────────────────────────────────────────────────────────
// ── Pub/sub ────────────────────────────────────────────────────────
onEvent(handler: SSEEventHandler): () => void {
this.handlers.add(handler)
return () => {
this.handlers.delete(handler)
}
}
onEvent(handler: SSEEventHandler): () => void {
this.handlers.add(handler)
return () => {
this.handlers.delete(handler)
}
}
onError(handler: SSEErrorHandler): () => void {
this.errorHandlers.add(handler)
return () => {
this.errorHandlers.delete(handler)
}
}
onError(handler: SSEErrorHandler): () => void {
this.errorHandlers.add(handler)
return () => {
this.errorHandlers.delete(handler)
}
}
onStateChange(handler: SSEStateHandler): () => void {
this.stateHandlers.add(handler)
return () => {
this.stateHandlers.delete(handler)
}
}
onStateChange(handler: SSEStateHandler): () => void {
this.stateHandlers.add(handler)
return () => {
this.stateHandlers.delete(handler)
}
}
// ── Internal ───────────────────────────────────────────────────────
// ── Internal ───────────────────────────────────────────────────────
/**
* Main reconnection loop — mirrors the pattern in `global-sdk.tsx`.
*/
private async consumeLoop(signal: AbortSignal): Promise<void> {
while (!signal.aborted) {
const attempt = new AbortController()
/**
* Main reconnection loop — mirrors the pattern in `global-sdk.tsx`.
*/
private async consumeLoop(signal: AbortSignal): Promise<void> {
while (!signal.aborted) {
const attempt = new AbortController()
// Forward the outer abort to the per-attempt controller so
// `disconnect()` cancels the current fetch immediately.
const onAbort = () => attempt.abort()
signal.addEventListener("abort", onAbort)
// Forward the outer abort to the per-attempt controller so
// `disconnect()` cancels the current fetch immediately.
const onAbort = () => attempt.abort()
signal.addEventListener("abort", onAbort)
try {
console.log("[Kilo New] SSE: 🎬 Calling SDK global.event()...")
const events = await this.client.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (signal.aborted) {
return
}
console.error("[Kilo New] SSE: ❌ SDK SSE error callback:", error)
this.notifyError(error instanceof Error ? error : new Error(String(error)))
},
})
try {
console.log("[Kilo New] SSE: 🎬 Calling SDK global.event()...")
const events = await this.client.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (signal.aborted) {
return
}
console.error("[Kilo New] SSE: ❌ SDK SSE error callback:", error)
this.notifyError(error instanceof Error ? error : new Error(String(error)))
},
})
console.log("[Kilo New] SSE: ✅ Stream opened successfully")
this.notifyState("connected")
this.resetHeartbeat(attempt)
console.log("[Kilo New] SSE: ✅ Stream opened successfully")
this.notifyState("connected")
this.resetHeartbeat(attempt)
for await (const event of events.stream) {
if (signal.aborted) {
break
}
for await (const event of events.stream) {
if (signal.aborted) {
break
}
this.resetHeartbeat(attempt)
this.resetHeartbeat(attempt)
// The SDK yields GlobalEvent = { directory, payload: Event }.
const globalEvent = event as GlobalEvent
console.log("[Kilo New] SSE: 📨 Event:", globalEvent.payload.type)
this.notifyEvent(globalEvent.payload)
}
// The SDK yields GlobalEvent = { directory, payload: Event }.
const globalEvent = event as GlobalEvent
console.log("[Kilo New] SSE: 📨 Event:", globalEvent.payload.type)
this.notifyEvent(globalEvent.payload)
}
console.log("[Kilo New] SSE: 📭 Stream ended normally")
} catch (error) {
if (!signal.aborted) {
console.error("[Kilo New] SSE: ❌ Stream error:", error)
this.notifyError(error instanceof Error ? error : new Error(String(error)))
}
} finally {
signal.removeEventListener("abort", onAbort)
this.clearHeartbeat()
}
console.log("[Kilo New] SSE: 📭 Stream ended normally")
} catch (error) {
if (!signal.aborted) {
console.error("[Kilo New] SSE: ❌ Stream error:", error)
this.notifyError(error instanceof Error ? error : new Error(String(error)))
}
} finally {
signal.removeEventListener("abort", onAbort)
this.clearHeartbeat()
}
if (signal.aborted) {
break
}
if (signal.aborted) {
break
}
console.log(
`[Kilo New] SSE: 🔄 Reconnecting in ${SdkSSEAdapter.RECONNECT_DELAY_MS}ms...`,
)
this.notifyState("connecting")
await new Promise((resolve) => setTimeout(resolve, SdkSSEAdapter.RECONNECT_DELAY_MS))
}
console.log(`[Kilo New] SSE: 🔄 Reconnecting in ${SdkSSEAdapter.RECONNECT_DELAY_MS}ms...`)
this.notifyState("connecting")
await new Promise((resolve) => setTimeout(resolve, SdkSSEAdapter.RECONNECT_DELAY_MS))
}
this.notifyState("disconnected")
}
this.notifyState("disconnected")
}
/**
* Reset the heartbeat timer. If no event arrives within the timeout
* window the per-attempt controller is aborted, causing the
* `for await` loop to exit and the outer loop to reconnect.
*/
private resetHeartbeat(attempt: AbortController): void {
this.clearHeartbeat()
this.heartbeatTimer = setTimeout(() => {
console.log("[Kilo New] SSE: ⏰ Heartbeat timeout — aborting stale connection")
attempt.abort()
}, SdkSSEAdapter.HEARTBEAT_TIMEOUT_MS)
}
/**
* Reset the heartbeat timer. If no event arrives within the timeout
* window the per-attempt controller is aborted, causing the
* `for await` loop to exit and the outer loop to reconnect.
*/
private resetHeartbeat(attempt: AbortController): void {
this.clearHeartbeat()
this.heartbeatTimer = setTimeout(() => {
console.log("[Kilo New] SSE: ⏰ Heartbeat timeout — aborting stale connection")
attempt.abort()
}, SdkSSEAdapter.HEARTBEAT_TIMEOUT_MS)
}
private clearHeartbeat(): void {
if (this.heartbeatTimer) {
clearTimeout(this.heartbeatTimer)
this.heartbeatTimer = null
}
}
private clearHeartbeat(): void {
if (this.heartbeatTimer) {
clearTimeout(this.heartbeatTimer)
this.heartbeatTimer = null
}
}
// ── Notify helpers ─────────────────────────────────────────────────
// ── Notify helpers ─────────────────────────────────────────────────
private notifyEvent(event: Event): void {
for (const handler of this.handlers) {
try {
handler(event)
} catch (error) {
console.error("[Kilo New] SSE: Error in event handler:", error)
}
}
}
private notifyEvent(event: Event): void {
for (const handler of this.handlers) {
try {
handler(event)
} catch (error) {
console.error("[Kilo New] SSE: Error in event handler:", error)
}
}
}
private notifyError(error: Error): void {
for (const handler of this.errorHandlers) {
try {
handler(error)
} catch (err) {
console.error("[Kilo New] SSE: Error in error handler:", err)
}
}
}
private notifyError(error: Error): void {
for (const handler of this.errorHandlers) {
try {
handler(error)
} catch (err) {
console.error("[Kilo New] SSE: Error in error handler:", err)
}
}
}
private notifyState(state: "connecting" | "connected" | "disconnected"): void {
for (const handler of this.stateHandlers) {
try {
handler(state)
} catch (error) {
console.error("[Kilo New] SSE: Error in state handler:", error)
}
}
}
private notifyState(state: "connecting" | "connected" | "disconnected"): void {
for (const handler of this.stateHandlers) {
try {
handler(state)
} catch (error) {
console.error("[Kilo New] SSE: Error in state handler:", error)
}
}
}
}
+3 -1
View File
@@ -1 +1,3 @@
{ "type": "module" }
{
"type": "module"
}
@@ -28,7 +28,11 @@ const mockVscode = {
extensions: {
getExtension: (id: string) => {
if (id === "vscode.git") return undefined
return { packageJSON: { version: "test", contributes: { configuration: { properties: {} } } }, isActive: true, exports: undefined }
return {
packageJSON: { version: "test", contributes: { configuration: { properties: {} } } },
isActive: true,
exports: undefined,
}
},
},
env: {
@@ -63,7 +67,10 @@ const mockVscode = {
CodeAction: class {
command?: { command: string; title: string }
isPreferred?: boolean
constructor(public title: string, public kind: { value: string }) {}
constructor(
public title: string,
public kind: { value: string },
) {}
},
CodeActionKind: {
QuickFix: kind("quickfix"),
@@ -78,10 +85,16 @@ const mockVscode = {
constructor(public uri: { scheme: string; fsPath: string }) {}
},
Position: class {
constructor(public line: number, public character: number) {}
constructor(
public line: number,
public character: number,
) {}
},
Range: class {
constructor(public start: { line: number; character: number }, public end: { line: number; character: number }) {}
constructor(
public start: { line: number; character: number },
public end: { line: number; character: number },
) {}
},
Disposable: class {
constructor(private callback: () => void = noop) {}
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d385bd579ac5587b1f70416196f77b6465d6a69fedf7270b889eedb29d0ac73d
size 14864
oid sha256:a3774fc422d473693e38fafba61c00632fd49d2a0c61d2332b9c31482af78a2b
size 15268
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5d69f44620c8e3a2f8d54ec97903c95cde04bef4655fc3b280a318e6da3cb0b5
size 9082
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:db0467fd219dfc19b4b27e32ff29c7f81e68091c58bb6b9209e6baa4e5f396ac
size 19507
oid sha256:b8b1ba56d6632bfcbd0e4c5b0c1514cd80832339dde0c86568572ada656ca33d
size 24134
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:04d857626cd157bf7f3325f50f48f5c9f95302a836f873cd9249aabbb6b22c8e
size 25422
oid sha256:278855516fe11d95b9de942d9690531c114b0a0b9ac0239c2ef31f82b420bd7e
size 32467
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ee4898a628110edf572fa27433bbb905d260225be0dd2154ccdfd4e4a2ea4c1e
size 4415
oid sha256:bcafa00cb615abbf253220f3b1c3163056bd18e1f5bf9632bc7229738936eff6
size 4311
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:22e44ea6da3326afe29e5eba35c3df0602ee84506f91d4d449c8cf770cd741b4
size 28468
oid sha256:f6193ba79b4d5dda29bf39d1127dacd7e332755d1f6f42dd67ff667088975659
size 35678
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cd8a53356f0aa0df5c07246296dc20273f5380a012cd579885b1974e3aeb99e1
size 8496
oid sha256:8f7fd08433524c113a2d7b3fed8bef5c023d9a451621f658b836c7967ca954aa
size 8310
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:36abb08cb4cc89b797d49ce2639c6f6e1086d83a6d373bf7821c1035253018b7
size 11181
oid sha256:2a225a66c7981745089a46f20841d5f214d23d905b8b31194cfba9f278a3763d
size 10965
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:607707e2201d1e50369a5897266136387b2444570b61ac2062c658335a0cdcc7
size 5055
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:64ff66248b1c07209757eb6e27ccac4967650b144317ee21076ba82c129bc17b
size 8846
oid sha256:d2c75a7bb06035f07f364c7fb7a1eb0b813663658d018e4eea881c48cc0e276c
size 8698
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:56517eed0845a01559a613124e5cf8f48945bbfc6f0fb6e3274ca5607954cb0c
size 1293
oid sha256:e0f7858b22d02fc7f30de4faae3f1f95ad7022e5e86741304b67d0f28f423e71
size 1296
@@ -329,12 +329,12 @@ const AgentManagerContent: Component = () => {
const [diffLoading, setDiffLoading] = createSignal(false)
const [diffWidth, setDiffWidth] = createSignal(Math.round(window.innerWidth * 0.5))
// Full-screen review state (in-memory, per worktree)
const [reviewOpenByWorktree, setReviewOpenByWorktree] = createSignal<Record<string, boolean>>({})
const [reviewCommentsByWorktree, setReviewCommentsByWorktree] = createSignal<Record<string, ReviewComment[]>>({})
// Full-screen review state (in-memory, per sidebar context: local/worktree)
const [reviewOpenByContext, setReviewOpenByContext] = createSignal<Record<string, boolean>>({})
const [reviewCommentsByContext, setReviewCommentsByContext] = createSignal<Record<string, ReviewComment[]>>({})
const [reviewActive, setReviewActive] = createSignal(false)
const [reviewDiffStyle, setReviewDiffStyle] = createSignal<"unified" | "split">("unified")
// reviewOpen (memo below) controls tab presence for selected worktree.
// reviewOpen (memo below) controls tab presence for selected context.
// Per-worktree git stats (diff additions/deletions, commits missing from origin)
const [worktreeStats, setWorktreeStats] = createSignal<Record<string, WorktreeGitStats>>({})
@@ -358,33 +358,33 @@ const AgentManagerContent: Component = () => {
const reviewOpen = createMemo(() => {
const sel = selection()
if (!sel || sel === LOCAL) return false
return reviewOpenByWorktree()[sel] === true
if (sel === null) return false
return reviewOpenByContext()[sel] === true
})
const setReviewOpenForWorktree = (worktreeId: string, open: boolean) => {
setReviewOpenByWorktree((prev) => {
if (prev[worktreeId] === open) return prev
return { ...prev, [worktreeId]: open }
const setReviewOpenForContext = (context: string, open: boolean) => {
setReviewOpenByContext((prev) => {
if (prev[context] === open) return prev
return { ...prev, [context]: open }
})
}
const setReviewOpenForSelection = (open: boolean) => {
const sel = selection()
if (!sel || sel === LOCAL) return
setReviewOpenForWorktree(sel, open)
if (sel === null) return
setReviewOpenForContext(sel, open)
}
const reviewComments = createMemo(() => {
const sel = selection()
if (!sel || sel === LOCAL) return [] as ReviewComment[]
return reviewCommentsByWorktree()[sel] ?? []
if (sel === null) return [] as ReviewComment[]
return reviewCommentsByContext()[sel] ?? []
})
const setReviewCommentsForSelection = (comments: ReviewComment[]) => {
const sel = selection()
if (!sel || sel === LOCAL) return
setReviewCommentsByWorktree((prev) => ({ ...prev, [sel]: comments }))
if (sel === null) return
setReviewCommentsByContext((prev) => ({ ...prev, [sel]: comments }))
}
const applyStateForSelection = createMemo(() => {
@@ -633,14 +633,14 @@ const AgentManagerContent: Component = () => {
createEffect(() => {
const ids = new Set(worktrees().map((wt) => wt.id))
setReviewOpenByWorktree((prev) => {
const next = Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id)))
setReviewOpenByContext((prev) => {
const next = Object.fromEntries(Object.entries(prev).filter(([id]) => id === LOCAL || ids.has(id)))
if (Object.keys(next).length === Object.keys(prev).length) return prev
return next
})
setReviewCommentsByWorktree((prev) => {
const next = Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id)))
setReviewCommentsByContext((prev) => {
const next = Object.fromEntries(Object.entries(prev).filter(([id]) => id === LOCAL || ids.has(id)))
if (Object.keys(next).length === Object.keys(prev).length) return prev
return next
})
@@ -722,7 +722,7 @@ const AgentManagerContent: Component = () => {
createEffect(() => {
const sel = selection()
if (!sel || sel === LOCAL) {
if (sel === null) {
if (reviewActive()) setReviewActive(false)
return
}
@@ -903,6 +903,7 @@ const AgentManagerContent: Component = () => {
session.clearCurrentSession()
vscode.postMessage({ type: "agentManager.showExistingLocalTerminal" })
}
setReviewActive(remembered === REVIEW_TAB_ID && reviewOpenByContext()[LOCAL] === true)
}
const selectWorktree = (worktreeId: string) => {
@@ -919,7 +920,7 @@ const AgentManagerContent: Component = () => {
} else {
session.setCurrentSessionID(undefined)
}
setReviewActive(remembered === REVIEW_TAB_ID && reviewOpenByWorktree()[worktreeId] === true)
setReviewActive(remembered === REVIEW_TAB_ID && reviewOpenByContext()[worktreeId] === true)
}
onMount(() => {
@@ -1288,9 +1289,13 @@ const AgentManagerContent: Component = () => {
return
}
if (review) {
// Review tab is open but no specific session — try using any session in the worktree
const sel = selection()
if (sel && sel !== LOCAL) {
// Review tab is open but no specific session — use local sentinel for local,
// or any session in the selected worktree.
if (sel === LOCAL) {
vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: LOCAL })
return
}
if (sel) {
const managed = managedSessions().find((ms) => ms.worktreeId === sel)
if (managed) {
vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: managed.id })
@@ -1311,9 +1316,9 @@ const AgentManagerContent: Component = () => {
const openReviewTab = () => {
const sel = selection()
if (!sel || sel === LOCAL) return
if (sel === null) return
setDiffOpen(false)
setReviewOpenForWorktree(sel, true)
setReviewOpenForContext(sel, true)
setReviewActive(true)
}
@@ -1333,16 +1338,18 @@ const AgentManagerContent: Component = () => {
setReviewOpenForSelection(false)
}
// Data for the review tab: use current session's diff data, or first available for the worktree
// Data for the review tab: use local diff data for local context,
// current session for selected worktree context, or first available in that worktree.
const reviewDiffs = createMemo(() => {
const data = diffDatas()
const sel = selection()
const id = session.currentSessionID()
if (sel === LOCAL) return data[LOCAL] ?? []
if (id && data[id]) {
const current = managedSessions().find((s) => s.id === id)
if (sel && sel !== LOCAL && current?.worktreeId === sel) return data[id]!
if (sel && current?.worktreeId === sel) return data[id]!
}
if (!sel || sel === LOCAL) return []
if (!sel) return []
const ids = managedSessions()
.filter((s) => s.worktreeId === sel)
.map((s) => s.id)
@@ -1569,8 +1576,9 @@ const AgentManagerContent: Component = () => {
const tabIds = createMemo(() => {
const ids = activeTabs().map((s) => s.id)
const sel = selection()
if (!sel || sel === LOCAL) return ids
if (sel === null) return ids
const current = reviewOpen() ? [...ids, REVIEW_TAB_ID] : ids
if (sel === LOCAL) return current
return applyTabOrder(
current.map((id) => ({ id })),
worktreeTabOrder()[sel],
@@ -1588,7 +1596,12 @@ const AgentManagerContent: Component = () => {
if (typeof from !== "string" || typeof to !== "string") return
const sel = selection()
if (sel === LOCAL) {
setLocalSessionIDs((prev) => reorderTabs(prev, from, to) ?? prev)
setLocalSessionIDs((prev) => {
const ids = reviewOpen() ? [...prev, REVIEW_TAB_ID] : prev
const reordered = reorderTabs(ids, from, to)
if (!reordered) return prev
return reordered.filter((id) => id !== REVIEW_TAB_ID)
})
return
}
if (sel) {
@@ -2037,7 +2050,7 @@ const AgentManagerContent: Component = () => {
placement="top"
>
<IconButton
icon="close-small"
icon="trash"
size="small"
variant="ghost"
label={t("agentManager.worktree.delete")}
@@ -2150,6 +2163,11 @@ const AgentManagerContent: Component = () => {
</Show>
)
})()}
<div class="am-hover-card-divider" />
<div class="am-hover-card-hint">
<Icon name="edit" size="small" />
<span>{t("agentManager.worktree.doubleClickRename")}</span>
</div>
</div>
</HoverCard>
</>
@@ -2418,7 +2436,7 @@ const AgentManagerContent: Component = () => {
</>
)
})()}
<Show when={selection() !== LOCAL}>
<Show when={selection() !== null}>
<Tooltip value={t("command.review.toggle")} placement="bottom">
<IconButton
icon="expand"
@@ -2568,7 +2586,7 @@ const AgentManagerContent: Component = () => {
comments={reviewComments()}
onCommentsChange={setReviewCommentsForSelection}
onClose={() => setDiffOpen(false)}
onExpand={selection() !== LOCAL ? openReviewTab : undefined}
onExpand={selection() !== null ? openReviewTab : undefined}
onOpenFile={(file) => {
const id = session.currentSessionID()
if (id) vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file })
@@ -37,6 +37,7 @@
width: 100%;
text-align: left;
color: var(--text-base);
font-family: inherit;
font-size: var(--font-size-base);
flex-shrink: 0;
}
@@ -50,15 +51,17 @@
}
.am-local-item-active {
background: var(--surface-base-active);
background: var(--surface-interactive-base);
color: var(--text-on-interactive-base);
}
.am-local-item-active .am-local-icon {
color: var(--text-base);
color: var(--text-on-interactive-base);
}
.am-local-item-active .am-local-branch {
color: var(--text-weak);
color: var(--text-on-interactive-base);
opacity: 0.7;
}
.am-local-item-active .am-stat-files {
@@ -469,6 +472,7 @@ button.am-section-toggle:hover .am-section-label {
width: 100%;
text-align: left;
color: var(--text-base);
font-family: inherit;
font-size: var(--font-size-base);
}
@@ -481,8 +485,8 @@ button.am-section-toggle:hover .am-section-label {
}
.am-item-active {
background: var(--surface-base-active);
color: var(--text-base);
background: var(--surface-interactive-base);
color: var(--text-on-interactive-base);
}
.am-item-title {
@@ -502,7 +506,8 @@ button.am-section-toggle:hover .am-section-label {
}
.am-item-active .am-item-time {
color: var(--text-weaker);
color: var(--text-on-interactive-base);
opacity: 0.7;
}
/* Promote button on session rows */
@@ -2042,6 +2047,18 @@ button.am-section-toggle:hover .am-section-label {
justify-content: flex-start;
}
.am-hover-card-hint {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-weaker);
font-size: 11px;
}
.am-hover-card-hint [data-component="icon"] {
opacity: 0.8;
}
/* Keyboard shortcuts dialog */
.am-shortcuts {
@@ -131,10 +131,10 @@ export const dict = {
"agentManager.review.sendToChat": "Pošalji u chat",
"agentManager.review.collapsedOnly": "{{count}} sažeto",
"agentManager.review.collapsedWithLarge": "{{collapsed}} sažeto, {{large}} velikih",
"agentManager.review.largeFileCollapsed": "Velika datoteka (skupljeno)",
"agentManager.review.endOfLongDiff": "Stigli ste do kraja!",
"agentManager.review.largeFileCollapsed": "Velika datoteka (sažeto)",
"agentManager.review.endOfLongDiff": "Došli ste do kraja!",
"agentManager.import.pullRequest": "Zahtjev za povlačenje",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "Zalijepite PR URL...",
"agentManager.import.open": "Otvori",
"agentManager.import.branches": "Branchevi",
@@ -131,10 +131,10 @@ export const dict = {
"agentManager.review.sendToChat": "Send til chat",
"agentManager.review.collapsedOnly": "{{count}} foldet sammen",
"agentManager.review.collapsedWithLarge": "{{collapsed}} foldet sammen, {{large}} store",
"agentManager.review.largeFileCollapsed": "Stor fil (kollapset)",
"agentManager.review.endOfLongDiff": "Du er nået til slutningen!",
"agentManager.review.largeFileCollapsed": "Stor fil (sammenklappet)",
"agentManager.review.endOfLongDiff": "Du nåede slutningen!",
"agentManager.import.pullRequest": "Fletteanmodning",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "Indsæt PR URL...",
"agentManager.import.open": "Åbn",
"agentManager.import.branches": "Branches",
@@ -132,10 +132,10 @@ export const dict = {
"agentManager.review.sendToChat": "An Chat senden",
"agentManager.review.collapsedOnly": "{{count}} eingeklappt",
"agentManager.review.collapsedWithLarge": "{{collapsed}} eingeklappt, {{large}} groß",
"agentManager.review.largeFileCollapsed": "Große Datei (zugeklappt)",
"agentManager.review.largeFileCollapsed": "Große Datei (eingeklappt)",
"agentManager.review.endOfLongDiff": "Du hast das Ende erreicht!",
"agentManager.import.pullRequest": "Zusammenführungsanfrage",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "PR-URL einfügen...",
"agentManager.import.open": "Öffnen",
"agentManager.import.branches": "Branches",
@@ -133,9 +133,9 @@ export const dict = {
"agentManager.review.collapsedOnly": "{{count}} contraídos",
"agentManager.review.collapsedWithLarge": "{{collapsed}} contraídos, {{large}} grandes",
"agentManager.review.largeFileCollapsed": "Archivo grande (contraído)",
"agentManager.review.endOfLongDiff": "¡Has llegado al final!",
"agentManager.review.endOfLongDiff": "¡Llegaste al final!",
"agentManager.import.pullRequest": "Solicitud de extracción",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "Pegar URL del PR...",
"agentManager.import.open": "Abrir",
"agentManager.import.branches": "Branches",
@@ -133,10 +133,10 @@ export const dict = {
"agentManager.review.sendToChat": "Envoyer au chat",
"agentManager.review.collapsedOnly": "{{count}} repliés",
"agentManager.review.collapsedWithLarge": "{{collapsed}} repliés, {{large}} volumineux",
"agentManager.review.largeFileCollapsed": "Fichier volumineux (réduit)",
"agentManager.review.largeFileCollapsed": "Fichier volumineux (replié)",
"agentManager.review.endOfLongDiff": "Vous êtes arrivé à la fin !",
"agentManager.import.pullRequest": "Demande d'extraction",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "Coller l'URL du PR...",
"agentManager.import.open": "Ouvrir",
"agentManager.import.branches": "Branches",
@@ -133,10 +133,10 @@ export const dict = {
"agentManager.review.sendToChat": "チャットに送信",
"agentManager.review.collapsedOnly": "{{count}} 件折りたたみ",
"agentManager.review.collapsedWithLarge": "{{collapsed}} 件折りたたみ、{{large}} 件がサイズ大",
"agentManager.review.largeFileCollapsed": "大きなファイル (折りたたみ済み)",
"agentManager.review.largeFileCollapsed": "大きなファイル(折りたたみ)",
"agentManager.review.endOfLongDiff": "最後まで到達しました!",
"agentManager.import.pullRequest": "プルリクエスト",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "PR URLを貼り付け...",
"agentManager.import.open": "開く",
"agentManager.import.branches": "ブランチ",
@@ -132,10 +132,10 @@ export const dict = {
"agentManager.review.sendToChat": "채팅으로 보내기",
"agentManager.review.collapsedOnly": "{{count}}개 접힘",
"agentManager.review.collapsedWithLarge": "{{collapsed}}개 접힘, {{large}}개 대용량",
"agentManager.review.largeFileCollapsed": "큰 파일 (접힘)",
"agentManager.review.endOfLongDiff": "끝까지 확인하셨습니다!",
"agentManager.review.largeFileCollapsed": "큰 파일(접힘)",
"agentManager.review.endOfLongDiff": "끝까지 도달했습니다!",
"agentManager.import.pullRequest": "풀 리퀘스트",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "PR URL 붙여넣기...",
"agentManager.import.open": "열기",
"agentManager.import.branches": "브랜치",
@@ -131,10 +131,10 @@ export const dict = {
"agentManager.review.sendToChat": "Send til chat",
"agentManager.review.collapsedOnly": "{{count}} kollapset",
"agentManager.review.collapsedWithLarge": "{{collapsed}} kollapset, {{large}} store",
"agentManager.review.largeFileCollapsed": "Stor fil (kollapset)",
"agentManager.review.endOfLongDiff": "Du har nådd slutten!",
"agentManager.review.largeFileCollapsed": "Stor fil (sammenfoldet)",
"agentManager.review.endOfLongDiff": "Du nådde slutten!",
"agentManager.import.pullRequest": "Fletteforespørsel",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "Lim inn PR URL...",
"agentManager.import.open": "Åpne",
"agentManager.import.branches": "Branches",
@@ -132,10 +132,10 @@ export const dict = {
"agentManager.review.sendToChat": "ส่งไปยังแชท",
"agentManager.review.collapsedOnly": "ยุบ {{count}} รายการ",
"agentManager.review.collapsedWithLarge": "ยุบ {{collapsed}} รายการ, ขนาดใหญ่ {{large}} รายการ",
"agentManager.review.largeFileCollapsed": "ไฟล์ขนาดใหญ่ (ย่อไว้)",
"agentManager.review.endOfLongDiff": "คุณมาถึงส่วนท้ายแล้ว!",
"agentManager.review.largeFileCollapsed": "ไฟล์ขนาดใหญ่ (พับอยู่)",
"agentManager.review.endOfLongDiff": "คุณมาถึงท้ายสุดแล้ว!",
"agentManager.import.pullRequest": "คำขอดึง",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "วาง URL ของ PR...",
"agentManager.import.open": "เปิด",
"agentManager.import.branches": "Branches",
@@ -131,9 +131,9 @@ export const dict = {
"agentManager.review.collapsedOnly": "{{count}} 个已折叠",
"agentManager.review.collapsedWithLarge": "{{collapsed}} 个已折叠,{{large}} 个过大",
"agentManager.review.largeFileCollapsed": "大文件(已折叠)",
"agentManager.review.endOfLongDiff": "您已到达末尾!",
"agentManager.review.endOfLongDiff": "你已经到末尾了!",
"agentManager.import.pullRequest": "拉取请求",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "粘贴 PR URL...",
"agentManager.import.open": "打开",
"agentManager.import.branches": "分支",
@@ -130,10 +130,10 @@ export const dict = {
"agentManager.review.sendToChat": "傳送到聊天",
"agentManager.review.collapsedOnly": "{{count}} 個已摺疊",
"agentManager.review.collapsedWithLarge": "{{collapsed}} 個已摺疊,{{large}} 個過大",
"agentManager.review.largeFileCollapsed": "大檔案(已摺疊)",
"agentManager.review.endOfLongDiff": "您已到達底部!",
"agentManager.review.largeFileCollapsed": "大型檔案(已摺疊)",
"agentManager.review.endOfLongDiff": "你已經到最後了!",
"agentManager.import.pullRequest": "拉取請求",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "貼上 PR URL...",
"agentManager.import.open": "開啟",
"agentManager.import.branches": "分支",
+1 -1
View File
@@ -170,7 +170,7 @@ const AppContent: Component = () => {
const handleViewAction = (action: string) => {
switch (action) {
case "plusButtonClicked":
session.clearCurrentSession()
window.dispatchEvent(new CustomEvent("newTaskRequest"))
setCurrentView("newTask")
break
case "marketplaceButtonClicked":
@@ -60,8 +60,7 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
}
// Questions linked to this message (rendered after the last part)
const questionForMessage = () =>
questions().find((q) => q.tool!.messageID === props.message.id)
const questionForMessage = () => questions().find((q) => q.tool!.messageID === props.message.id)
const [responding, setResponding] = createSignal(false)
@@ -79,7 +78,7 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
const perm = () => permissionForPart(part)
return (
<Show when={PART_MAPPING[part.type]}>
<div data-component="tool-part-wrapper" data-permission={!!perm()}>
<div data-component="tool-part-wrapper" data-permission={!!perm()} data-part-type={part.type}>
<Part
part={part}
message={props.message as SDKMessage}
@@ -88,7 +87,7 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
/>
<Show when={perm()} keyed>
{(p) => (
<div data-component="permission-prompt">
<div data-component="permission-prompt" onClick={(e: MouseEvent) => e.stopPropagation()}>
<Show when={p.patterns.length > 0}>
<div class="permission-dock-patterns">
<For each={p.patterns}>
@@ -97,13 +96,28 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
</div>
</Show>
<div data-slot="permission-actions">
<Button variant="ghost" size="small" onClick={() => decide(p.id, "reject")} disabled={responding()}>
<Button
variant="ghost"
size="small"
onClick={() => decide(p.id, "reject")}
disabled={responding()}
>
{language.t("ui.permission.deny")}
</Button>
<Button variant="secondary" size="small" onClick={() => decide(p.id, "always")} disabled={responding()}>
<Button
variant="secondary"
size="small"
onClick={() => decide(p.id, "always")}
disabled={responding()}
>
{language.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="small" onClick={() => decide(p.id, "once")} disabled={responding()}>
<Button
variant="primary"
size="small"
onClick={() => decide(p.id, "once")}
disabled={responding()}
>
{language.t("ui.permission.allowOnce")}
</Button>
</div>
@@ -3,7 +3,7 @@
* Main chat container that combines all chat components
*/
import { Component, For, Show, createSignal, onCleanup, onMount } from "solid-js"
import { Component, For, Show, createSignal, createEffect, on, onCleanup, onMount } from "solid-js"
import { Button } from "@kilocode/kilo-ui/button"
import { BasicTool } from "@kilocode/kilo-ui/basic-tool"
import { TaskHeader } from "./TaskHeader"
@@ -23,6 +23,8 @@ export const ChatView: Component<ChatViewProps> = (props) => {
const language = useLanguage()
const id = () => session.currentSessionID()
const hasMessages = () => session.messages().length > 0
const idle = () => session.status() !== "busy"
const sessionQuestions = () => session.questions().filter((q) => q.sessionID === id())
const sessionPermissions = () => session.permissions().filter((p) => p.sessionID === id())
@@ -30,6 +32,17 @@ export const ChatView: Component<ChatViewProps> = (props) => {
const permissionRequest = () => sessionPermissions().find((p) => !p.tool)
const blocked = () => sessionPermissions().length > 0 || sessionQuestions().length > 0
// When a bottom-dock permission/question disappears while the session is busy,
// the scroll container grows taller. Dispatch a custom event so MessageList can
// resume auto-scroll.
createEffect(
on(blocked, (isBlocked, wasBlocked) => {
if (wasBlocked && !isBlocked && !idle()) {
window.dispatchEvent(new CustomEvent("resumeAutoScroll"))
}
}),
)
const [responding, setResponding] = createSignal(false)
onMount(() => {
@@ -102,6 +115,19 @@ export const ChatView: Component<ChatViewProps> = (props) => {
</div>
)}
</Show>
<Show when={hasMessages() && idle() && !blocked()}>
<div class="new-task-button-wrapper">
<Button
variant="secondary"
size="small"
data-full-width="true"
onClick={() => window.dispatchEvent(new CustomEvent("newTaskRequest"))}
aria-label={language.t("command.session.new.task")}
>
{language.t("command.session.new.task")}
</Button>
</div>
</Show>
<Show when={!blocked()}>
<PromptInput />
</Show>
@@ -7,7 +7,7 @@
* Shows recent sessions in the empty state for quick resumption.
*/
import { Component, For, Show, createEffect, createMemo, JSX } from "solid-js"
import { Component, For, Show, createEffect, createMemo, onCleanup, JSX } from "solid-js"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Button } from "@kilocode/kilo-ui/button"
import { useDialog } from "@kilocode/kilo-ui/context/dialog"
@@ -48,6 +48,11 @@ export const MessageList: Component<MessageListProps> = (props) => {
overflowAnchor: "dynamic",
})
// Resume auto-scroll when a bottom-dock permission/question is dismissed
const onResumeAutoScroll = () => autoScroll.resume()
window.addEventListener("resumeAutoScroll", onResumeAutoScroll)
onCleanup(() => window.removeEventListener("resumeAutoScroll", onResumeAutoScroll))
let loaded = false
createEffect(() => {
if (!loaded && server.isConnected() && session.sessions().length === 0) {
@@ -72,6 +72,16 @@ export const PromptInput: Component = () => {
window.addEventListener("focusPrompt", onFocusPrompt)
onCleanup(() => window.removeEventListener("focusPrompt", onFocusPrompt))
// Start a new task, carrying over the current prompt text (without auto-sending it)
const onNewTaskRequest = () => {
const prompt = text().trim()
// Pre-populate the draft for the new (empty) session so the effect restores it
if (prompt) drafts.set("__new__", prompt)
session.clearCurrentSession()
}
window.addEventListener("newTaskRequest", onNewTaskRequest)
onCleanup(() => window.removeEventListener("newTaskRequest", onNewTaskRequest))
const isBusy = () => session.status() === "busy"
const isDisabled = () => !server.isConnected()
const canSend = () => (text().trim().length > 0 || imageAttach.images().length > 0) && !isBusy() && !isDisabled()
@@ -1,12 +1,13 @@
/**
* QuestionDock component
* Displays question requests from the AI assistant inline above the prompt input.
* Uses kilo-ui's data-component/data-slot CSS pattern for styling.
* Uses kilo-ui's DockPrompt component for proper surface styling.
*/
import { Component, For, Show, createMemo, createEffect } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@kilocode/kilo-ui/button"
import { DockPrompt } from "@kilocode/kilo-ui/dock-prompt"
import { Icon } from "@kilocode/kilo-ui/icon"
import { useSession } from "../../context/session"
import { useLanguage } from "../../context/language"
@@ -148,10 +149,11 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
}
return (
<div data-component="tool-part-wrapper" data-question="true">
<div data-component="question-prompt">
<div data-slot="question-body">
<div data-slot="question-header">
<div onClick={(e: MouseEvent) => e.stopPropagation()}>
<DockPrompt
kind="question"
header={
<>
<div data-slot="question-header-title">{summary()}</div>
<Show when={!single()}>
<div data-slot="question-progress">
@@ -177,62 +179,65 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
</button>
</div>
</Show>
</div>
<Show when={!confirm()}>
<div data-slot="question-content">
<div data-slot="question-text">{question()?.question}</div>
<Show
when={multi()}
fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}
>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</>
}
footer={
<>
<Button variant="ghost" size="small" onClick={reject} disabled={store.sending}>
{language.t("ui.common.dismiss")}
</Button>
<div data-slot="question-footer-actions">
<Show when={store.tab > 0}>
<Button variant="secondary" size="small" onClick={back} disabled={store.sending}>
{language.t("ui.common.back")}
</Button>
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
disabled={store.sending}
onClick={() => selectOption(i())}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</button>
)
}}
</For>
<Show when={question()?.custom !== false}>
<Show
when={confirm()}
fallback={
<Button
variant={last() && single() ? "primary" : "secondary"}
size="small"
onClick={last() && single() ? submit : () => selectTab(store.tab + 1)}
disabled={store.sending || (!confirm() && (store.answers[store.tab]?.length ?? 0) === 0)}
>
{last() && single()
? language.t("ui.common.submit")
: last()
? language.t("common.review")
: language.t("ui.common.next")}
</Button>
}
>
<Button variant="primary" size="small" onClick={submit} disabled={store.sending}>
{language.t("ui.common.submit")}
</Button>
</Show>
</div>
</>
}
>
<Show when={!confirm()}>
<div data-slot="question-text">{question()?.question}</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-custom="true"
data-picked={customPicked()}
data-picked={picked()}
disabled={store.sending}
onClick={() => selectOption(options().length)}
onClick={() => selectOption(i())}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={customPicked()}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
@@ -240,101 +245,94 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description" data-placeholder={!input()}>
{input() || language.t("ui.question.custom.placeholder")}
</span>
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</button>
<Show when={store.editing}>
<form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
<input
ref={(el) => setTimeout(() => el.focus(), 0)}
type="text"
data-slot="custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
disabled={store.sending}
onInput={(e) => {
const inputs = [...store.custom]
inputs[store.tab] = e.currentTarget.value
setStore("custom", inputs)
}}
/>
<Button type="submit" variant="primary" size="small" disabled={store.sending}>
{multi() ? language.t("ui.common.add") : language.t("ui.common.submit")}
</Button>
<Button
type="button"
variant="ghost"
size="small"
disabled={store.sending}
onClick={() => setStore("editing", false)}
>
{language.t("ui.common.cancel")}
</Button>
</form>
</Show>
</Show>
</div>
</div>
</Show>
<Show when={confirm()}>
<div data-slot="question-review">
<div data-slot="review-title">{language.t("ui.messagePart.review.title")}</div>
<For each={questions()}>
{(q, index) => {
const value = () => store.answers[index()]?.join(", ") ?? ""
const answered = () => Boolean(value())
return (
<div data-slot="review-item">
<span data-slot="review-label">{q.question}</span>
<span data-slot="review-value" data-answered={answered()}>
{answered() ? value() : language.t("ui.question.review.notAnswered")}
</span>
</div>
)
}}
</For>
</div>
</Show>
</div>
<div data-slot="question-footer">
<Button variant="ghost" size="small" onClick={reject} disabled={store.sending}>
{language.t("ui.common.dismiss")}
</Button>
<div data-slot="question-footer-actions">
<Show when={store.tab > 0}>
<Button variant="secondary" size="small" onClick={back} disabled={store.sending}>
{language.t("ui.common.back")}
</Button>
</Show>
<Show
when={confirm()}
fallback={
<Button
variant={last() && single() ? "primary" : "secondary"}
size="small"
onClick={last() && single() ? submit : () => selectTab(store.tab + 1)}
disabled={store.sending || (!confirm() && (store.answers[store.tab]?.length ?? 0) === 0)}
>
{last() && single()
? language.t("ui.common.submit")
: last()
? language.t("common.review")
: language.t("ui.common.next")}
</Button>
}
>
<Button variant="primary" size="small" onClick={submit} disabled={store.sending}>
{language.t("ui.common.submit")}
</Button>
)
}}
</For>
<Show when={question()?.custom !== false}>
<button
data-slot="question-option"
data-custom="true"
data-picked={customPicked()}
disabled={store.sending}
onClick={() => selectOption(options().length)}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={customPicked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description" data-placeholder={!input()}>
{input() || language.t("ui.question.custom.placeholder")}
</span>
</span>
</button>
<Show when={store.editing}>
<form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
<input
ref={(el) => setTimeout(() => el.focus(), 0)}
type="text"
data-slot="custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
disabled={store.sending}
onInput={(e) => {
const inputs = [...store.custom]
inputs[store.tab] = e.currentTarget.value
setStore("custom", inputs)
}}
/>
<Button type="submit" variant="primary" size="small" disabled={store.sending}>
{multi() ? language.t("ui.common.add") : language.t("ui.common.submit")}
</Button>
<Button
type="button"
variant="ghost"
size="small"
disabled={store.sending}
onClick={() => setStore("editing", false)}
>
{language.t("ui.common.cancel")}
</Button>
</form>
</Show>
</Show>
</div>
</div>
</div>
</Show>
<Show when={confirm()}>
<div data-slot="question-review">
<div data-slot="review-title">{language.t("ui.messagePart.review.title")}</div>
<For each={questions()}>
{(q, index) => {
const value = () => store.answers[index()]?.join(", ") ?? ""
const answered = () => Boolean(value())
return (
<div data-slot="review-item">
<span data-slot="review-label">{q.question}</span>
<span data-slot="review-value" data-answered={answered()}>
{answered() ? value() : language.t("ui.question.review.notAnswered")}
</span>
</div>
)
}}
</For>
</div>
</Show>
</DockPrompt>
</div>
)
}
@@ -43,6 +43,7 @@ export const dict = {
"command.language.set": "استخدام اللغة: {{language}}",
"command.session.new": "جلسة جديدة",
"command.session.new.task": "مهمة جديدة",
"command.file.open": "فتح ملف",
"command.tab.close": "إغلاق علامة التبويب",
"command.context.addSelection": "إضافة التحديد إلى السياق",
@@ -971,7 +972,6 @@ export const dict = {
// Agent Manager strings live in webview-ui/agent-manager/i18n/ar.ts
"question.summary": "{{n}} من {{total}} أسئلة",
"common.review": "مراجعة",
@@ -43,6 +43,7 @@ export const dict = {
"command.language.set": "Usar idioma: {{language}}",
"command.session.new": "Nova sessão",
"command.session.new.task": "Nova tarefa",
"command.file.open": "Abrir arquivo",
"command.tab.close": "Fechar aba",
"command.context.addSelection": "Adicionar seleção ao contexto",
@@ -985,7 +986,6 @@ export const dict = {
// Agent Manager strings live in webview-ui/agent-manager/i18n/br.ts
"question.summary": "{{n}} de {{total}} perguntas",
"common.review": "Revisar",
@@ -43,6 +43,7 @@ export const dict = {
"command.language.set": "Koristi jezik: {{language}}",
"command.session.new": "Nova sesija",
"command.session.new.task": "Novi zadatak",
"command.file.open": "Otvori datoteku",
"command.tab.close": "Zatvori karticu",
"command.context.addSelection": "Dodaj odabir u kontekst",
@@ -984,7 +985,6 @@ export const dict = {
// Agent Manager strings live in webview-ui/agent-manager/i18n/bs.ts
"question.summary": "{{n}} od {{total}} pitanja",
"common.review": "Pregled",
@@ -43,6 +43,7 @@ export const dict = {
"command.language.set": "Brug sprog: {{language}}",
"command.session.new": "Ny session",
"command.session.new.task": "Ny opgave",
"command.file.open": "Åbn fil",
"command.tab.close": "Luk fane",
"command.context.addSelection": "Tilføj markering til kontekst",
@@ -980,7 +981,6 @@ export const dict = {
// Agent Manager strings live in webview-ui/agent-manager/i18n/da.ts
"question.summary": "{{n}} af {{total}} spørgsmål",
"common.review": "Gennemgå",
@@ -47,6 +47,7 @@ export const dict = {
"command.language.set": "Sprache verwenden: {{language}}",
"command.session.new": "Neue Sitzung",
"command.session.new.task": "Neue Aufgabe",
"command.file.open": "Datei öffnen",
"command.tab.close": "Tab schließen",
"command.context.addSelection": "Auswahl zum Kontext hinzufügen",
@@ -997,7 +998,6 @@ export const dict = {
// Agent Manager strings live in webview-ui/agent-manager/i18n/de.ts
"question.summary": "{{n}} von {{total}} Fragen",
"common.review": "Überprüfen",
@@ -43,6 +43,7 @@ export const dict = {
"command.language.set": "Use language: {{language}}",
"command.session.new": "New session",
"command.session.new.task": "New task",
"command.file.open": "Open file",
"command.tab.close": "Close tab",
"command.context.addSelection": "Add selection to context",
@@ -43,6 +43,7 @@ export const dict = {
"command.language.set": "Usar idioma: {{language}}",
"command.session.new": "Nueva sesión",
"command.session.new.task": "Nueva tarea",
"command.file.open": "Abrir archivo",
"command.tab.close": "Cerrar pestaña",
"command.context.addSelection": "Añadir selección al contexto",
@@ -987,7 +988,6 @@ export const dict = {
"profile.personalAccount": "Cuenta personal",
// Agent Manager strings live in webview-ui/agent-manager/i18n/es.ts
"question.summary": "{{n}} de {{total}} preguntas",
"common.review": "Revisar",
@@ -43,6 +43,7 @@ export const dict = {
"command.language.set": "Utiliser la langue : {{language}}",
"command.session.new": "Nouvelle session",
"command.session.new.task": "Nouvelle tâche",
"command.file.open": "Ouvrir un fichier",
"command.tab.close": "Fermer l'onglet",
"command.context.addSelection": "Ajouter la sélection au contexte",
@@ -71,7 +72,8 @@ export const dict = {
"command.permissions.autoaccept.enable": "Accepter automatiquement les modifications",
"command.permissions.autoaccept.disable": "Arrêter l'acceptation automatique des modifications",
"command.workspace.toggle": "Basculer les espaces de travail",
"command.workspace.toggle.description": "Activer ou désactiver les espaces de travail multiples dans la barre latérale",
"command.workspace.toggle.description":
"Activer ou désactiver les espaces de travail multiples dans la barre latérale",
"command.session.undo": "Annuler",
"command.session.undo.description": "Annuler le dernier message",
"command.session.redo": "Rétablir",
@@ -995,7 +997,6 @@ export const dict = {
"profile.personalAccount": "Compte personnel",
// Agent Manager strings live in webview-ui/agent-manager/i18n/fr.ts
"question.summary": "{{n}} sur {{total}} questions",
"common.review": "Réviser",
@@ -43,6 +43,7 @@ export const dict = {
"command.language.set": "言語を使用: {{language}}",
"command.session.new": "新しいセッション",
"command.session.new.task": "新しいタスク",
"command.file.open": "ファイルを開く",
"command.tab.close": "タブを閉じる",
"command.context.addSelection": "選択範囲をコンテキストに追加",
@@ -979,7 +980,6 @@ export const dict = {
"profile.personalAccount": "個人アカウント",
// Agent Manager strings live in webview-ui/agent-manager/i18n/ja.ts
"question.summary": "{{total}} 問中 {{n}} 問目",
"common.review": "確認",
@@ -47,6 +47,7 @@ export const dict = {
"command.language.set": "언어 사용: {{language}}",
"command.session.new": "새 세션",
"command.session.new.task": "새 작업",
"command.file.open": "파일 열기",
"command.tab.close": "탭 닫기",
"command.context.addSelection": "선택 영역을 컨텍스트에 추가",
@@ -976,7 +977,6 @@ export const dict = {
"profile.personalAccount": "개인 계정",
// Agent Manager strings live in webview-ui/agent-manager/i18n/ko.ts
"question.summary": "{{total}}개 질문 중 {{n}}번째",
"common.review": "검토",
@@ -46,6 +46,7 @@ export const dict = {
"command.language.set": "Bruk språk: {{language}}",
"command.session.new": "Ny sesjon",
"command.session.new.task": "Ny oppgave",
"command.file.open": "Åpne fil",
"command.tab.close": "Lukk fane",
"command.context.addSelection": "Legg til markering i kontekst",
@@ -981,7 +982,6 @@ export const dict = {
"profile.personalAccount": "Personlig konto",
// Agent Manager strings live in webview-ui/agent-manager/i18n/no.ts
"question.summary": "{{n}} av {{total}} spørsmål",
"common.review": "Gjennomgå",
@@ -43,6 +43,7 @@ export const dict = {
"command.language.set": "Użyj języka: {{language}}",
"command.session.new": "Nowa sesja",
"command.session.new.task": "Nowe zadanie",
"command.file.open": "Otwórz plik",
"command.tab.close": "Zamknij kartę",
"command.context.addSelection": "Dodaj zaznaczenie do kontekstu",
@@ -982,7 +983,6 @@ export const dict = {
"profile.personalAccount": "Konto osobiste",
// Agent Manager strings live in webview-ui/agent-manager/i18n/pl.ts
"question.summary": "{{n}} z {{total}} pytań",
"common.review": "Przejrzyj",
@@ -43,6 +43,7 @@ export const dict = {
"command.language.set": "Использовать язык: {{language}}",
"command.session.new": "Новая сессия",
"command.session.new.task": "Новая задача",
"command.file.open": "Открыть файл",
"command.tab.close": "Закрыть вкладку",
"command.context.addSelection": "Добавить выделение в контекст",
@@ -984,7 +985,6 @@ export const dict = {
"profile.personalAccount": "Личный аккаунт",
// Agent Manager strings live in webview-ui/agent-manager/i18n/ru.ts
"question.summary": "{{n}} из {{total}} вопросов",
"common.review": "Просмотр",
@@ -43,6 +43,7 @@ export const dict = {
"command.language.set": "ใช้ภาษา: {{language}}",
"command.session.new": "เซสชันใหม่",
"command.session.new.task": "งานใหม่",
"command.file.open": "เปิดไฟล์",
"command.tab.close": "ปิดแท็บ",
"command.context.addSelection": "เพิ่มส่วนที่เลือกไปยังบริบท",
@@ -972,7 +973,6 @@ export const dict = {
"profile.personalAccount": "บัญชีส่วนตัว",
// Agent Manager strings live in webview-ui/agent-manager/i18n/th.ts
"question.summary": "{{n}} จาก {{total}} คำถาม",
"common.review": "ตรวจสอบ",
@@ -47,6 +47,7 @@ export const dict = {
"command.language.set": "使用语言:{{language}}",
"command.session.new": "新建会话",
"command.session.new.task": "新建任务",
"command.file.open": "打开文件",
"command.tab.close": "关闭标签页",
"command.context.addSelection": "将所选内容添加到上下文",
@@ -967,7 +968,6 @@ export const dict = {
"profile.personalAccount": "个人账户",
// Agent Manager strings live in webview-ui/agent-manager/i18n/zh.ts
"question.summary": "第 {{n}} / {{total}} 个问题",
"common.review": "审查",
@@ -47,6 +47,7 @@ export const dict = {
"command.language.set": "使用語言:{{language}}",
"command.session.new": "新增工作階段",
"command.session.new.task": "新增任務",
"command.file.open": "開啟檔案",
"command.tab.close": "關閉分頁",
"command.context.addSelection": "將選取內容加入上下文",
@@ -968,7 +969,6 @@ export const dict = {
"profile.personalAccount": "個人帳戶",
// Agent Manager strings live in webview-ui/agent-manager/i18n/zht.ts
"question.summary": "第 {{n}} / {{total}} 個問題",
"common.review": "審查",
@@ -8,9 +8,10 @@
*/
import type { Meta, StoryObj } from "storybook-solidjs-vite"
import { StoryProviders } from "./StoryProviders"
import { StoryProviders, mockSessionValue } from "./StoryProviders"
import { ChatView } from "../components/chat/ChatView"
import { QuestionDock } from "../components/chat/QuestionDock"
import { SessionContext } from "../context/session"
import type { QuestionRequest } from "../types/messages"
const SESSION_ID = "story-session-chat-001"
@@ -69,7 +70,7 @@ const multiQuestion: QuestionRequest = {
const meta: Meta = {
title: "Chat",
parameters: { layout: "padded" },
parameters: { layout: "fullscreen" },
}
export default meta
type Story = StoryObj
@@ -82,13 +83,35 @@ export const ChatViewIdle: Story = {
name: "ChatView — idle (empty)",
render: () => (
<StoryProviders sessionID={SESSION_ID} status="idle">
<div style={{ width: "420px", height: "600px", display: "flex", "flex-direction": "column" }}>
<div style={{ width: "100%", height: "600px", display: "flex", "flex-direction": "column" }}>
<ChatView />
</div>
</StoryProviders>
),
}
/** ChatView with messages — shows the full-width "New task" button above the prompt */
export const ChatViewWithMessages: Story = {
name: "ChatView — with messages (shows New Task button)",
render: () => {
const session = {
...mockSessionValue({ id: SESSION_ID, status: "idle" }),
messages: () => [{ id: "msg-001" }] as any[],
totalCost: () => 0.0012,
contextUsage: () => ({ tokens: 512, percentage: 6 }),
}
return (
<StoryProviders sessionID={SESSION_ID} status="idle" noPadding>
<SessionContext.Provider value={session as any}>
<div style={{ width: "100%", height: "200px", display: "flex", "flex-direction": "column" }}>
<ChatView />
</div>
</SessionContext.Provider>
</StoryProviders>
)
},
}
// ---------------------------------------------------------------------------
// QuestionDock stories
// ---------------------------------------------------------------------------
@@ -97,7 +120,7 @@ export const QuestionDockSingle: Story = {
name: "QuestionDock — single question",
render: () => (
<StoryProviders sessionID={SESSION_ID} questions={[singleQuestion]}>
<div style={{ width: "420px" }}>
<div style={{ width: "100%" }}>
<QuestionDock request={singleQuestion} />
</div>
</StoryProviders>
@@ -108,7 +131,7 @@ export const QuestionDockMulti: Story = {
name: "QuestionDock — multi-question wizard",
render: () => (
<StoryProviders sessionID={SESSION_ID} questions={[multiQuestion]}>
<div style={{ width: "420px" }}>
<div style={{ width: "100%" }}>
<QuestionDock request={multiQuestion} />
</div>
</StoryProviders>
@@ -231,6 +231,23 @@ const questionToolPart: ToolPart = {
},
}
const questionDismissedPart: ToolPart = {
id: "part-question-dismissed-001",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "tool",
callID: "call-question-dismissed-001",
tool: "question",
state: {
status: "error",
input: { question: "Which testing framework?", options: [] },
error: "Error: User dismissed this question",
title: "Question dismissed",
metadata: {},
time: { start: now - 2000, end: now - 1500 },
},
}
// ---------------------------------------------------------------------------
// Data helpers
// ---------------------------------------------------------------------------
@@ -244,9 +261,7 @@ function dataWith(parts: any[], permissions?: PermissionRequest[]) {
part: {
[ASST_MSG_ID]: parts,
},
permission: permissions
? { [SESSION_ID]: permissions }
: {},
permission: permissions ? { [SESSION_ID]: permissions } : {},
}
}
@@ -318,9 +333,7 @@ export const PermissionDock: Story = {
>
<Show when={perm.patterns.length > 0}>
<div class="permission-dock-patterns">
<For each={perm.patterns}>
{(pattern) => <code class="permission-dock-pattern">{pattern}</code>}
</For>
<For each={perm.patterns}>{(pattern) => <code class="permission-dock-pattern">{pattern}</code>}</For>
</div>
</Show>
</BasicTool>
@@ -461,3 +474,19 @@ export const InlineQuestion: Story = {
)
},
}
// ---------------------------------------------------------------------------
// 9. Dismissed question (right-aligned "Questions dismissed" text)
// ---------------------------------------------------------------------------
export const QuestionDismissed: Story = {
name: "Question Dismissed",
render: () => {
const data = dataWith([textPart, questionDismissedPart])
return (
<StoryProviders data={data} sessionID={SESSION_ID}>
<AssistantMessage message={baseAssistantMessage} />
</StoryProviders>
)
},
}
@@ -9,7 +9,7 @@ import { ModelSelectorBase } from "../components/shared/ModelSelector"
const meta: Meta = {
title: "Shared",
parameters: { layout: "padded" },
parameters: { layout: "fullscreen" },
}
export default meta
type Story = StoryObj
@@ -41,6 +41,11 @@
border-top: 1px solid var(--vscode-panel-border);
}
/* New Task Button */
.new-task-button-wrapper {
padding: 8px 12px 0;
}
/* ============================================
Message List
============================================ */
@@ -907,6 +912,10 @@
padding: 0 4px;
}
.vscode-session-turn + .vscode-session-turn {
margin-top: 12px;
}
.vscode-session-turn-user {
width: 100%;
}
@@ -932,14 +941,10 @@
}
/* ============================================
Question Prompt
Question Prompt (VS Code overrides)
============================================ */
[data-component="question-prompt"] {
[data-slot="question-header"] {
padding: 0;
}
[data-component="dock-prompt"][data-kind="question"] {
[data-slot="question-progress-nav"] {
display: inline-flex;
align-items: center;
@@ -967,12 +972,6 @@
}
}
[data-slot="question-footer"] {
padding: 8px;
padding-top: 8px;
margin-top: 0;
}
[data-slot="question-footer"] > [data-component="button"][data-variant="ghost"] {
border: none;
box-shadow: none;
@@ -992,155 +991,11 @@
color: var(--text-weaker);
}
[data-slot="question-text"] {
font-size: var(--font-size-base);
padding: 0 2px;
}
[data-slot="question-hint"] {
font-size: var(--font-size-small);
padding: 0 2px;
}
[data-slot="question-options"] {
margin-top: 8px;
gap: 4px;
padding: 1px 1px 4px;
}
[data-slot="question-option"] {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 8px;
background-color: var(--surface-raised-stronger-non-alpha);
border: 1px solid var(--border-weak-base);
border-radius: var(--radius-lg);
box-shadow: none;
text-align: left;
width: 100%;
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
&:hover:not([data-picked="true"]) {
background-color: var(--background-base);
}
&[data-picked="true"] {
background-color: var(--surface-interactive-weak);
border-color: transparent;
box-shadow: var(--shadow-xs-border-hover);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
[data-slot="question-option-check"] {
display: inline-flex;
transform: translateY(2px);
}
[data-slot="question-option-box"] {
width: 16px;
height: 16px;
padding: 2px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-weak-base);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background-color: transparent;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
[data-component="icon"] {
opacity: 0;
color: var(--icon-base);
}
&[data-type="radio"] {
border-radius: 999px;
}
[data-slot="question-option-radio-dot"] {
width: 6px;
height: 6px;
border-radius: 999px;
background-color: var(--background-stronger);
opacity: 0;
}
&[data-picked="true"] {
border-color: var(--icon-interactive-base);
[data-component="icon"] {
opacity: 1;
color: var(--icon-invert-base);
}
&[data-type="checkbox"] {
background-color: var(--icon-interactive-base);
}
&[data-type="radio"] {
background-color: var(--icon-interactive-base);
[data-slot="question-option-radio-dot"] {
opacity: 1;
}
}
}
}
[data-slot="question-option-main"] {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
[data-slot="option-label"] {
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
color: var(--text-strong);
}
[data-slot="option-description"] {
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
color: var(--text-base);
min-width: 0;
white-space: normal;
overflow: visible;
text-overflow: clip;
}
[data-slot="question-option"][data-custom="true"] {
[data-slot="option-description"] {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
}
}
[data-slot="question-review"] {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px 2px;
padding: 4px 10px;
}
[data-slot="review-title"] {
+1
View File
@@ -273,6 +273,7 @@ export namespace Server {
Bus, // kilocode_change
SessionCreatedEvent: Session.Event.Created, // kilocode_change
Identifier, // kilocode_change
ModelCache, // kilocode_change
}),
)
// kilocode_change end
@@ -121,3 +121,52 @@ test("model fetch without OAuth accountId does not set kilocodeOrganizationId",
},
})
})
test("ModelCache.clear removes cached entry so next fetch hits the network", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://app.kilo.ai/config.json",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
await Auth.set("kilo", {
type: "oauth",
access: "token-clear-test",
refresh: "refresh-clear",
expires: Date.now() + 3600000,
accountId: "org-clear",
})
},
fn: async () => {
// Populate cache
captured = undefined
ModelCache.clear("kilo")
await ModelCache.fetch("kilo")
expect(captured).toBeDefined()
// Verify cache is populated — second fetch should NOT call fetchKiloModels
captured = undefined
await ModelCache.fetch("kilo")
expect(captured).toBeUndefined()
expect(ModelCache.get("kilo")).toBeDefined()
// Clear the cache
ModelCache.clear("kilo")
// get() should return undefined after clear
expect(ModelCache.get("kilo")).toBeUndefined()
// Next fetch should call fetchKiloModels again
captured = undefined
await ModelCache.fetch("kilo")
expect(captured).toBeDefined()
},
})
})
@@ -115,6 +115,7 @@
}
&.subagent-link {
cursor: pointer;
color: var(--text-interactive-base);
text-decoration: none;
text-underline-offset: 2px;
+17 -9
View File
@@ -964,22 +964,29 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
const render = ToolRegistry.render(part.tool) ?? GenericTool
const dismissed = createMemo(
() =>
part.tool === "question" &&
part.state.status === "error" &&
typeof part.state.error === "string" &&
part.state.error.includes("dismissed this question"),
)
return (
<Show when={!hideQuestion()}>
<Show when={dismissed()}>
<div style="width: 100%; display: flex; justify-content: flex-end; padding: 4px 8px 4px 0;">
<span class="text-13-regular text-text-weak cursor-default">
{i18n.t("ui.tool.questions")} dismissed
</span>
</div>
</Show>
<Show when={!dismissed()}>
<div data-component="tool-part-wrapper">
<Switch>
<Match when={part.state.status === "error" && part.state.error}>
{(error) => {
const cleaned = error().replace("Error: ", "")
if (part.tool === "question" && cleaned.includes("dismissed this question")) {
return (
<div style="width: 100%; display: flex; justify-content: flex-end;">
<span class="text-13-regular text-text-weak cursor-default">
{i18n.t("ui.tool.questions")} dismissed
</span>
</div>
)
}
const [title, ...rest] = cleaned.split(": ")
return (
<Card variant="error">
@@ -1016,6 +1023,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
</Match>
</Switch>
</div>
</Show>
</Show>
)
}