mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
Merge branch 'main' into puddle-barometer
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Stop broad permission rules from letting Ask and Plan modes change your workspace. Catch-all approvals, the "Allow everything" toggle, and the `<command> *` rules that "Always allow" persists no longer grant these modes shell commands, subagents, notebook edits or other mutating tools, and MCP tools go back to prompting. To opt a single mode in, set `agent.ask.permission` or `agent.plan.permission` instead of a top-level `permission` rule.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep the Agent Manager inline diff position stable while scrolling upward through large reviews
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Prevent concurrent Kilo processes from crashing while recovering the shared SQLite WAL.
|
||||
@@ -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 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Start Kilo with a persistent fallback when the default runtime state directory is not writable.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Accept JWT share tokens when importing a session from a Kilo share URL.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix Agent Manager PR and base-branch worktrees when a repository uses a restrictive Git fetch refspec.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep the Allow and Deny buttons reachable when a permission prompt contains a large diff or a long command: the prompt now scrolls its own content and shrinks with the available chat height instead of pushing its buttons out of view
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Persist disabling snapshots from the slow-repo prompt across restarts.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Prevent subagent descriptions from appearing as Agent Manager worktree titles.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Keep `kilo upgrade` on the Kilo CLI release channel when GitHub's latest release is a JetBrains release.
|
||||
@@ -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": minor
|
||||
---
|
||||
|
||||
Open delegated subagent sessions in Agent Manager inspector tabs alongside terminals.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Keep recently used Kilo Gateway models visible in the TUI picker, and find them when filtering by kilo.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Open `/sessions` on a git worktree with the Worktree history filter selected by default.
|
||||
@@ -27,9 +27,11 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDatabase
|
||||
|
||||
yield* db.run("PRAGMA journal_mode = WAL")
|
||||
yield* db.run("PRAGMA synchronous = NORMAL")
|
||||
// kilocode_change start - install SQLite's busy handler before concurrent processes can race to recover the WAL
|
||||
yield* db.run("PRAGMA busy_timeout = 5000")
|
||||
yield* db.run("PRAGMA journal_mode = WAL")
|
||||
// kilocode_change end
|
||||
yield* db.run("PRAGMA synchronous = NORMAL")
|
||||
yield* db.run("PRAGMA cache_size = -64000")
|
||||
yield* db.run("PRAGMA foreign_keys = ON")
|
||||
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
@@ -42,7 +44,7 @@ const layer = Layer.effect(
|
||||
|
||||
export function layerFromPath(filename: string) {
|
||||
DbPreflight.assertWritable(filename) // kilocode_change - actionable error (and self-heal for kilo-owned files) instead of an opaque wal_checkpoint crash on read-only db files
|
||||
return layer.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
return layer.pipe(Layer.provide(sqliteLayer({ filename, disableWAL: true }))) // kilocode_change - Database configures WAL after busy_timeout
|
||||
}
|
||||
|
||||
export function path() {
|
||||
|
||||
@@ -5,7 +5,7 @@ import os from "os"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Flock } from "./util/flock"
|
||||
import { markNoIndex } from "./kilocode/spotlight" // kilocode_change
|
||||
import { ensureRealDir } from "./kilocode/global" // kilocode_change
|
||||
import { ensureRealDir, resolveState } from "./kilocode/global" // kilocode_change
|
||||
import { Flag } from "./flag/flag"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
|
||||
@@ -22,7 +22,8 @@ const clean = (p: string | undefined) => p?.replace(/[\r\n]+/g, "")
|
||||
const data = path.join(clean(xdgData)!, app)
|
||||
const cache = path.join(clean(xdgCache)!, app)
|
||||
const config = path.join(clean(xdgConfig)!, app)
|
||||
const state = path.join(clean(xdgState)!, app)
|
||||
const preferred = path.join(clean(xdgState)!, app)
|
||||
const state = await resolveState(preferred, process.env.XDG_STATE_HOME ? undefined : path.join(data, "state"))
|
||||
// kilocode_change end
|
||||
const tmp = path.join(os.tmpdir(), app)
|
||||
|
||||
@@ -47,7 +48,6 @@ Flock.setGlobal({ state })
|
||||
await Promise.all([
|
||||
ensureRealDir(Path.data), // kilocode_change
|
||||
ensureRealDir(Path.config), // kilocode_change
|
||||
ensureRealDir(Path.state), // kilocode_change
|
||||
ensureRealDir(Path.tmp), // kilocode_change
|
||||
ensureRealDir(Path.log), // kilocode_change
|
||||
ensureRealDir(Path.bin), // kilocode_change
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { randomUUID } from "crypto"
|
||||
|
||||
/**
|
||||
* Like `fs.mkdir({ recursive: true })` but also repairs broken symlinks and
|
||||
@@ -21,3 +23,50 @@ export async function ensureRealDir(p: string) {
|
||||
await fs.mkdir(p, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function writable(p: string) {
|
||||
const probe = path.join(p, `.kilo-write-${process.pid}-${randomUUID()}`)
|
||||
await fs.writeFile(probe, "", { flag: "wx", mode: 0o600 })
|
||||
await fs.unlink(probe)
|
||||
}
|
||||
|
||||
async function ready(p: string) {
|
||||
await ensureRealDir(p)
|
||||
await writable(p)
|
||||
}
|
||||
|
||||
export async function resolveState(p: string, fallback?: string) {
|
||||
const sticky =
|
||||
fallback === undefined
|
||||
? false
|
||||
: await fs.stat(fallback).then(
|
||||
(stat) =>
|
||||
stat.isDirectory() &&
|
||||
writable(fallback).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
() => false,
|
||||
)
|
||||
if (sticky && fallback !== undefined) return fallback
|
||||
|
||||
const err = await ready(p).then(
|
||||
() => undefined,
|
||||
(err: unknown) => err,
|
||||
)
|
||||
if (err === undefined) return p
|
||||
if (fallback === undefined) throw err
|
||||
|
||||
const failed = await ready(fallback).then(
|
||||
() => undefined,
|
||||
(err: unknown) => err,
|
||||
)
|
||||
if (failed !== undefined) {
|
||||
throw new AggregateError([err, failed], `Cannot use state directory "${p}" or fallback "${fallback}"`)
|
||||
}
|
||||
|
||||
const msg = err instanceof Error ? err.message : "Unknown error"
|
||||
// Logging is not initialized until Global.Path.log exists.
|
||||
console.warn(`[kilo] Cannot use state directory "${p}"; using "${fallback}" instead: ${msg}`)
|
||||
return fallback
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
|
||||
const wait = async (dir: string, glob: string, count = 1, end = Date.now() + 5_000): Promise<void> => {
|
||||
const files = await Array.fromAsync(new Bun.Glob(glob).scan({ cwd: dir }))
|
||||
if (files.length >= count) return
|
||||
if (Date.now() >= end) throw new Error(`Timed out waiting for ${glob}`)
|
||||
await Bun.sleep(1)
|
||||
return wait(dir, glob, count, end)
|
||||
}
|
||||
|
||||
const remove = async (file: string, retry = 30): Promise<void> => {
|
||||
try {
|
||||
await fs.rm(file, { force: true })
|
||||
} catch (err) {
|
||||
if (retry === 0 || !err || typeof err !== "object" || !("code" in err) || err.code !== "EBUSY") throw err
|
||||
await Bun.sleep(100)
|
||||
return remove(file, retry - 1)
|
||||
}
|
||||
}
|
||||
|
||||
describe("database WAL recovery", () => {
|
||||
test("starts concurrent processes while recovering an abandoned WAL", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "kilo.db")
|
||||
await Effect.runPromise(Layer.build(Database.layerFromPath(file).pipe(Layer.fresh)).pipe(Effect.scoped))
|
||||
|
||||
const worker = path.join(import.meta.dir, "fixture/database-recovery-worker.ts")
|
||||
const seed = Bun.spawn([process.execPath, worker, "seed", tmp.path], { stdout: "ignore", stderr: "pipe" })
|
||||
try {
|
||||
await wait(tmp.path, "seed-ready")
|
||||
} finally {
|
||||
if (seed.exitCode === null) seed.kill(9)
|
||||
await seed.exited
|
||||
}
|
||||
|
||||
await remove(`${file}-shm`)
|
||||
const children: (typeof seed)[] = []
|
||||
try {
|
||||
for (const _ of Array.from({ length: 12 }))
|
||||
children.push(Bun.spawn([process.execPath, worker, "open", tmp.path], { stdout: "ignore", stderr: "pipe" }))
|
||||
await wait(tmp.path, "open-ready-*", children.length)
|
||||
await Bun.write(path.join(tmp.path, "start"), "")
|
||||
|
||||
const statuses = await Promise.all(children.map((child) => child.exited))
|
||||
const errors = await Promise.all(children.map((child) => new Response(child.stderr).text()))
|
||||
if (statuses.some((status) => status !== 0)) throw new Error(errors.filter(Boolean).join("\n"))
|
||||
expect(statuses).toEqual(Array.from({ length: children.length }, () => 0))
|
||||
} finally {
|
||||
for (const child of children) if (child.exitCode === null) child.kill(9)
|
||||
await Promise.all(children.map((child) => child.exited))
|
||||
}
|
||||
}, 20_000)
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Database as SQLite } from "bun:sqlite"
|
||||
import path from "path"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
const mode = process.argv[2]
|
||||
const dir = process.argv[3]
|
||||
if (!mode || !dir) throw new Error("Expected mode and data directory")
|
||||
|
||||
const file = path.join(dir, "kilo.db")
|
||||
|
||||
if (mode === "seed") {
|
||||
const sqlite = new SQLite(file)
|
||||
sqlite.run("PRAGMA journal_mode = WAL")
|
||||
sqlite.run("PRAGMA wal_autocheckpoint = 0")
|
||||
sqlite.run("CREATE TABLE recovery_load (value BLOB)")
|
||||
const insert = sqlite.prepare("INSERT INTO recovery_load VALUES (?)")
|
||||
const value = new Uint8Array(4096)
|
||||
sqlite.transaction(() => {
|
||||
for (const _ of Array.from({ length: 1_000 })) insert.run(value)
|
||||
})()
|
||||
await Bun.write(path.join(dir, "seed-ready"), "")
|
||||
await new Promise(() => {})
|
||||
}
|
||||
|
||||
if (mode === "open") {
|
||||
await Bun.write(path.join(dir, `open-ready-${process.pid}`), "")
|
||||
while (!(await Bun.file(path.join(dir, "start")).exists())) await Bun.sleep(1)
|
||||
await Effect.runPromise(Layer.build(Database.layerFromPath(file).pipe(Layer.fresh)).pipe(Effect.scoped))
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { resolveState } from "@opencode-ai/core/kilocode/global"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
|
||||
const skip = process.platform === "win32" || process.getuid?.() === 0
|
||||
|
||||
describe("global state directory", () => {
|
||||
test("uses the preferred state directory when available", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const preferred = path.join(tmp.path, "preferred")
|
||||
|
||||
expect(await resolveState(preferred, path.join(tmp.path, "fallback"))).toBe(preferred)
|
||||
expect((await fs.stat(preferred)).isDirectory()).toBe(true)
|
||||
expect(await fs.readdir(preferred)).toEqual([])
|
||||
})
|
||||
|
||||
test("falls back when the default state directory is unusable", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const preferred = path.join(tmp.path, "preferred")
|
||||
const fallback = path.join(tmp.path, "data", "state")
|
||||
await fs.writeFile(preferred, "not a directory")
|
||||
|
||||
expect(await resolveState(preferred, fallback)).toBe(fallback)
|
||||
expect((await fs.stat(fallback)).isDirectory()).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps using an existing fallback", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const preferred = path.join(tmp.path, "preferred")
|
||||
const fallback = path.join(tmp.path, "fallback")
|
||||
await fs.mkdir(fallback)
|
||||
|
||||
expect(await resolveState(preferred, fallback)).toBe(fallback)
|
||||
expect(
|
||||
await fs.stat(preferred).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test.skipIf(skip)("uses the preferred directory when an existing fallback is not writable", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const preferred = path.join(tmp.path, "preferred")
|
||||
const fallback = path.join(tmp.path, "fallback")
|
||||
await fs.mkdir(fallback)
|
||||
await fs.chmod(fallback, 0o500)
|
||||
|
||||
try {
|
||||
expect(await resolveState(preferred, fallback)).toBe(preferred)
|
||||
} finally {
|
||||
await fs.chmod(fallback, 0o700)
|
||||
}
|
||||
})
|
||||
|
||||
test.skipIf(skip)("falls back when the preferred directory cannot be created", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const parent = path.join(tmp.path, "preferred")
|
||||
const preferred = path.join(parent, "kilo")
|
||||
const fallback = path.join(tmp.path, "data", "state")
|
||||
await fs.mkdir(parent)
|
||||
await fs.chmod(parent, 0o500)
|
||||
|
||||
try {
|
||||
expect(await resolveState(preferred, fallback)).toBe(fallback)
|
||||
} finally {
|
||||
await fs.chmod(parent, 0o700)
|
||||
}
|
||||
})
|
||||
|
||||
test.skipIf(skip)("falls back when the preferred directory exists but is not writable", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const preferred = path.join(tmp.path, "preferred")
|
||||
const fallback = path.join(tmp.path, "data", "state")
|
||||
await fs.mkdir(preferred)
|
||||
await fs.chmod(preferred, 0o500)
|
||||
|
||||
try {
|
||||
expect(await resolveState(preferred, fallback)).toBe(fallback)
|
||||
} finally {
|
||||
await fs.chmod(preferred, 0o700)
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves errors for explicitly configured state directories", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const preferred = path.join(tmp.path, "preferred")
|
||||
await fs.writeFile(preferred, "not a directory")
|
||||
|
||||
const err = await resolveState(preferred).catch((err: unknown) => err)
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
test("reports both paths when the fallback also fails", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const preferred = path.join(tmp.path, "preferred")
|
||||
const fallback = path.join(tmp.path, "fallback")
|
||||
await Promise.all([fs.writeFile(preferred, "not a directory"), fs.writeFile(fallback, "not a directory")])
|
||||
|
||||
const err = await resolveState(preferred, fallback).catch((err: unknown) => err)
|
||||
expect(err).toBeInstanceOf(AggregateError)
|
||||
if (!(err instanceof AggregateError)) throw err
|
||||
expect(err.message).toContain(preferred)
|
||||
expect(err.message).toContain(fallback)
|
||||
expect(err.errors).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c1115e3b2056f1b00c6b9a657f61c3540e00b14ec0ff4fb04f072157bb5059b3
|
||||
size 17935
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:12f523463f86c86dffc350a32c0f76e39391f602c32f0b778d410c87cc83fb1a
|
||||
size 15708
|
||||
oid sha256:627a7ab79c52de6663138ab748994cfc4fb3022601d388e0ef5dd538c54d63fa
|
||||
size 16379
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:58b145cc30b2d7580ea859dcd27b88535d33983d4125004586ba2568e3024252
|
||||
size 15889
|
||||
oid sha256:18098fafe8eb05a020d8070e1d8783611750cdad4d71025f81eaa42d62ba75f5
|
||||
size 16005
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ee6ea794cf665570dcc208ad5975a14843d1680e59eb785fb5298b4919b7dd1d
|
||||
size 25757
|
||||
oid sha256:501a1d8eee78345bcd538df3b9df6f9f5f6b49903aff66a7f2fd15827c66371f
|
||||
size 22159
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ee9fb41fb522684de6aa7fd9acda8357f33f2ba90bcb3eb7679ddfbcdecc6cdb
|
||||
size 20764
|
||||
oid sha256:6ac541494bb7faf06bd89d51aaf81dbd405945911e9570b370b7741e398734e5
|
||||
size 17822
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d544d3964f4a442b12d1eb47aa992c756ac537c2c810adb9abb072f99a815135
|
||||
size 17177
|
||||
oid sha256:679a9d8cf02036ea103f93f1c7c2df9b5c329044bd302ba10141f9d34495663e
|
||||
size 17000
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e7905a190dff49bc7e23af1773ea93ac79cc71659336469745561355cba5f413
|
||||
size 22602
|
||||
oid sha256:d60096bf740a7503ba0724b01dc94830ddaeb70d02d86777e481269b445ff629
|
||||
size 21137
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:83a8fca09c11e19b37dac155ea5856677141a7bac46450b3557ebf82b4f2a3f7
|
||||
size 12963
|
||||
oid sha256:800358fb3669a879eaa0228e72f3b623c275b88fa810bab4a3d6f0e015b5d84e
|
||||
size 15307
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:10956634c051d1d1eff54229083434ddb1882adc49161d524a14ab3fc3859554
|
||||
size 13822
|
||||
oid sha256:83673f48e121d5e181a8700f6eb7e091d2ef14ce17552647e0552881977fd0f1
|
||||
size 16209
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0249acab24874167ad0f6e88d12897be50dc12861e763a89a4d90545309de3b3
|
||||
size 15709
|
||||
oid sha256:5c3ba074f2080ac4ad03adb7a1bc81ca56efefd29ca8fd1186d5c9d928bcd0af
|
||||
size 15828
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dc61e88dc65c15583e0c972f2d370e1c75c7a87949c557492ffbc146b2f75ac5
|
||||
size 15535
|
||||
oid sha256:21d9d9ef350b57515cb956fba4bdbc5d0801c6fe4cef6f18983ae809741480a5
|
||||
size 15652
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d76fe59b3a6b07d89243d1f9bc434eb3e813d361e0e59db97bd4916097279e2a
|
||||
size 15842
|
||||
oid sha256:4b5438ebc5b9f7ec214315b0a91f931815da087c11dcd3db99f17e196d367c84
|
||||
size 16736
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:04037b1aac2456ba09ffafc1bed2cd1c9c00a09cf926453518231b1bd14fc974
|
||||
size 16470
|
||||
oid sha256:b9b06384010b48a7a2624ab895f4ef8baebcdba4e0d7e123f2cc0dd3b6748b8a
|
||||
size 16304
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b197d749f21df8d3061b93d13281a346b52514bec1b6855b474a03cfaeeb117b
|
||||
size 2500
|
||||
oid sha256:4b2de227517c16c9aa90af0245da92f8e532ad29e21de2142e50121dbebf55fc
|
||||
size 1574
|
||||
|
||||
@@ -24,6 +24,7 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
||||
"selectedLines",
|
||||
"commentedLines",
|
||||
"virtualized",
|
||||
"sizeKey",
|
||||
])
|
||||
const workerPool = useWorkerPool(props.diffStyle)
|
||||
|
||||
|
||||
@@ -23,6 +23,23 @@ const MIN_PLACEHOLDER_HEIGHT = 160
|
||||
const MAX_PLACEHOLDER_HEIGHT = 1200
|
||||
type Job = { run: () => void; cancelled: boolean }
|
||||
|
||||
const sizes = new WeakMap<object, Map<number, number>>()
|
||||
const WIDTH_LIMIT = 8
|
||||
|
||||
function remember(key: object | undefined, width: number, height: number) {
|
||||
if (!key || width <= 0 || height <= 0) return
|
||||
const widths = sizes.get(key) ?? new Map<number, number>()
|
||||
widths.delete(width)
|
||||
widths.set(width, height)
|
||||
if (widths.size > WIDTH_LIMIT) widths.delete(widths.keys().next().value!)
|
||||
sizes.set(key, widths)
|
||||
}
|
||||
|
||||
function reserved(key: object | undefined, width: number) {
|
||||
if (!key || width <= 0) return
|
||||
return sizes.get(key)?.get(width)
|
||||
}
|
||||
|
||||
// A review can contain many expanded diff components. Creating one
|
||||
// IntersectionObserver per diff showed up in profiles, so all deferred diffs
|
||||
// share a single observer and only register their element + render callback.
|
||||
@@ -173,6 +190,7 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
"commentedLines",
|
||||
"onRendered",
|
||||
"virtualized",
|
||||
"sizeKey",
|
||||
])
|
||||
|
||||
const mobile = createMediaQuery("(max-width: 640px)")
|
||||
@@ -247,7 +265,7 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
|
||||
createEffect(() => {
|
||||
if (visible()) return
|
||||
container.style.minHeight = `${estimate()}px`
|
||||
container.style.minHeight = `${reserved(local.sizeKey, container.clientWidth) ?? estimate()}px`
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -266,6 +284,17 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
return root
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof ResizeObserver === "undefined") return
|
||||
const resize = new ResizeObserver(() => {
|
||||
const root = getRoot()
|
||||
if (!visible() || !current() || !root?.querySelector("[data-line]")) return
|
||||
remember(local.sizeKey, container.clientWidth, container.offsetHeight)
|
||||
})
|
||||
resize.observe(container)
|
||||
onCleanup(() => resize.disconnect())
|
||||
})
|
||||
|
||||
const applyScheme = () => {
|
||||
const host = container.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return
|
||||
@@ -370,6 +399,7 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
if (token !== renderToken) return
|
||||
// Clear the height pin now that Pierre has rendered new content.
|
||||
container.style.minHeight = ""
|
||||
remember(local.sizeKey, container.clientWidth, container.offsetHeight)
|
||||
setSelectedLines(lastSelection)
|
||||
local.onRendered?.()
|
||||
})
|
||||
@@ -411,6 +441,7 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
if (typeof MutationObserver === "undefined") {
|
||||
container.style.minHeight = ""
|
||||
if (!root || !isReady(root)) return
|
||||
remember(local.sizeKey, container.clientWidth, container.offsetHeight)
|
||||
setSelectedLines(lastSelection)
|
||||
local.onRendered?.()
|
||||
return
|
||||
@@ -777,6 +808,7 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
if (!instance) return
|
||||
instance.setLineAnnotations(annotations ?? [])
|
||||
instance.rerender()
|
||||
notifyRendered()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
|
||||
@@ -58,6 +58,9 @@ type DiffShared<T> = FileDiffOptions<T> & {
|
||||
// files so eager rendering does not expand full before/after content.
|
||||
// Defaults to virtualized.
|
||||
virtualized?: boolean
|
||||
// Stable rendered-content identity used to preserve deferred height when a
|
||||
// surrounding row virtualizer unmounts and later re-creates this diff.
|
||||
sizeKey?: object
|
||||
class?: string
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -320,7 +320,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
// Session events from sync or older backends can lack time/directory; a
|
||||
// throw here would escape into the SSE dispatch loop and starve the other
|
||||
// listeners (there is no per-listener error isolation).
|
||||
if (!info?.time || !dir) return
|
||||
if (!info?.time || !dir || (info.parentID !== undefined && info.parentID !== null)) return
|
||||
const ctx = this.contexts.byDirectory(dir)
|
||||
if (!ctx || ctx.lifecycle !== "ready") return
|
||||
const state = ctx.peekState()
|
||||
|
||||
@@ -839,6 +839,8 @@ export class WorktreeManager {
|
||||
private async refreshBase(branch: string, requested?: string): Promise<void> {
|
||||
const remote = requested ?? (await this.resolveRemote())
|
||||
if (!remote) return
|
||||
validateGitRef(remote, "remote")
|
||||
validateGitRef(branch, "branch")
|
||||
const key = `${this.root}:${remote}:${branch}`
|
||||
const cached = WorktreeManager.fetchCache.get(key)
|
||||
if (cached && Date.now() - cached < WorktreeManager.FETCH_CACHE_TTL) return
|
||||
@@ -849,7 +851,7 @@ export class WorktreeManager {
|
||||
const env = nonInteractiveEnv()
|
||||
await simpleGit(this.root, { unsafe: { allowUnsafeSshCommand: isKiloOwnedSshCommand(env) } })
|
||||
.env(env)
|
||||
.fetch(remote, branch, { "--quiet": null, "--no-tags": null })
|
||||
.raw(["fetch", "--quiet", "--no-tags", remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`])
|
||||
WorktreeManager.fetchCache.set(key, Date.now())
|
||||
}
|
||||
|
||||
@@ -1107,10 +1109,17 @@ export class WorktreeManager {
|
||||
if (!remotes.some((r) => r.name === forkOwner)) {
|
||||
await this.git.addRemote(forkOwner, `https://github.com/${forkOwner}/${parsed.repo}.git`)
|
||||
}
|
||||
await this.gitExec(["fetch", forkOwner, info.headRefName])
|
||||
await this.gitExec([
|
||||
"fetch",
|
||||
"--quiet",
|
||||
"--no-tags",
|
||||
forkOwner,
|
||||
`+refs/heads/${info.headRefName}:refs/remotes/${forkOwner}/${info.headRefName}`,
|
||||
])
|
||||
} else {
|
||||
validateGitRef(info.headRefName, "branch name")
|
||||
const ok = await this.gitTry(["fetch", "origin", info.headRefName])
|
||||
const ref = `+refs/heads/${info.headRefName}:refs/remotes/origin/${info.headRefName}`
|
||||
const ok = await this.gitTry(["fetch", "--quiet", "--no-tags", "origin", ref])
|
||||
if (!ok) {
|
||||
await this.gitExec([
|
||||
"fetch",
|
||||
@@ -1118,6 +1127,14 @@ export class WorktreeManager {
|
||||
`+refs/pull/${parsed.number}/head:refs/remotes/origin/${info.headRefName}`,
|
||||
])
|
||||
}
|
||||
if (!(await this.gitTry(["show-ref", "--verify", "--quiet", `refs/heads/${info.headRefName}`]))) {
|
||||
const start = `refs/remotes/origin/${info.headRefName}`
|
||||
await this.gitExec(["branch", info.headRefName, start])
|
||||
if (ok) {
|
||||
await this.gitExec(["config", `branch.${info.headRefName}.remote`, "origin"])
|
||||
await this.gitExec(["config", `branch.${info.headRefName}.merge`, `refs/heads/${info.headRefName}`])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ export async function collectProjectSessions(
|
||||
const out: ProjectSessionView[] = []
|
||||
for (const { dir, worktreeId, items } of byDir) {
|
||||
for (const s of items) {
|
||||
if (s.parentID !== undefined && s.parentID !== null) continue
|
||||
if (seen.has(s.id)) continue
|
||||
seen.add(s.id)
|
||||
sessions.setSessionDirectory(s.id, dir)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,11 +2,16 @@ import { expect, test, type Page } from "@playwright/test"
|
||||
|
||||
const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern"
|
||||
const STORY_ID = "agentmanager--full-screen-diff-agent-edit-scroll"
|
||||
const INLINE_STORY_ID = "agentmanager--diff-panel-scroll-up"
|
||||
|
||||
function storyUrl() {
|
||||
return `/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`
|
||||
}
|
||||
|
||||
function inlineStoryUrl() {
|
||||
return `/iframe.html?id=${INLINE_STORY_ID}&viewMode=story&globals=${GLOBALS}`
|
||||
}
|
||||
|
||||
async function disableAnimations(page: Page) {
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
@@ -159,3 +164,60 @@ test("resets virtual measurements and scroll when the review context changes", a
|
||||
await expect.poll(async () => first.evaluate((el) => el.getBoundingClientRect().height)).toBe(1_200)
|
||||
await expect.poll(async () => scroller.evaluate((el) => el.scrollTop)).toBe(0)
|
||||
})
|
||||
|
||||
test("keeps the inline diff position stable while scrolling upward", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 900, height: 760 })
|
||||
await page.goto(inlineStoryUrl(), { waitUntil: "load" })
|
||||
await disableAnimations(page)
|
||||
await page.waitForSelector(".am-diff-content diffs-container", { state: "attached" })
|
||||
|
||||
const result = await page.locator(".am-diff-content").evaluate(async (el) => {
|
||||
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve))
|
||||
const settle = async (count: number) => {
|
||||
for (let i = 0; i < count; i++) await frame()
|
||||
}
|
||||
const seen = new Set(
|
||||
Array.from(el.querySelectorAll("[data-file-path]"), (row) => row.getAttribute("data-file-path")),
|
||||
)
|
||||
let remounts = 0
|
||||
const observer = new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
for (const node of record.addedNodes) {
|
||||
if (!(node instanceof HTMLElement)) continue
|
||||
const rows = node.matches("[data-file-path]") ? [node] : Array.from(node.querySelectorAll("[data-file-path]"))
|
||||
for (const row of rows) {
|
||||
const file = row.getAttribute("data-file-path")
|
||||
if (seen.has(file)) remounts++
|
||||
seen.add(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
observer.observe(el, { childList: true, subtree: true })
|
||||
|
||||
// Materialize every row once, then start from the settled bottom. The bug
|
||||
// appears when upward scrolling re-creates rows above the viewport.
|
||||
while (el.scrollTop < el.scrollHeight - el.clientHeight - 1) {
|
||||
el.scrollTop = Math.min(el.scrollHeight - el.clientHeight, el.scrollTop + 120)
|
||||
await frame()
|
||||
}
|
||||
await settle(30)
|
||||
|
||||
let correction = 0
|
||||
let range = 0
|
||||
while (el.scrollTop > 0) {
|
||||
const height = el.scrollHeight
|
||||
const intended = Math.max(0, el.scrollTop - 80)
|
||||
el.scrollTop = intended
|
||||
await settle(2)
|
||||
correction = Math.max(correction, Math.abs(el.scrollTop - intended))
|
||||
range = Math.max(range, Math.abs(el.scrollHeight - height))
|
||||
}
|
||||
observer.disconnect()
|
||||
return { correction, range, remounts }
|
||||
})
|
||||
|
||||
expect(result.remounts).toBeGreaterThan(0)
|
||||
expect(result.correction).toBeLessThanOrEqual(1)
|
||||
expect(result.range).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -204,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.
|
||||
|
||||
@@ -7,6 +7,7 @@ test("edit approval diff shows line numbers in compact viewer", async ({ page })
|
||||
await page.setViewportSize({ width: 420, height: 720 })
|
||||
await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" })
|
||||
|
||||
await page.locator('[data-slot="permission-diff"]').scrollIntoViewIfNeeded()
|
||||
const number = page.locator('[data-slot="permission-diff-content"] [data-column-number]').first()
|
||||
await expect(number).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -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"),
|
||||
@@ -51,6 +52,8 @@ const TSX_FILES = [
|
||||
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"),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { mergeWorktreeDiffs } from "../../webview-ui/diff-viewer/diff-state"
|
||||
import { diffSizeKey, mergeWorktreeDiffs } from "../../webview-ui/diff-viewer/diff-state"
|
||||
import {
|
||||
EXTREME_DIFF_CHANGED_LINES,
|
||||
allOpenFiles,
|
||||
@@ -28,6 +28,18 @@ function diff(overrides: Partial<WorktreeFileDiff>): WorktreeFileDiff {
|
||||
}
|
||||
}
|
||||
|
||||
describe("diffSizeKey", () => {
|
||||
it("changes with rendered content, style, and review context", () => {
|
||||
const base = diff({ summarized: false, patch: "@@ -1 +1 @@\n-old\n+new\n" })
|
||||
const key = diffSizeKey("review-a", base, "unified")
|
||||
|
||||
expect(diffSizeKey("review-a", base, "unified")).toBe(key)
|
||||
expect(diffSizeKey("review-b", base, "unified")).not.toBe(key)
|
||||
expect(diffSizeKey("review-a", base, "split")).not.toBe(key)
|
||||
expect(diffSizeKey("review-a", { ...base, patch: "@@ -1 +1 @@\n-old\n+newer\n" }, "unified")).not.toBe(key)
|
||||
})
|
||||
})
|
||||
|
||||
describe("agent manager diff state", () => {
|
||||
it("preserves loaded detail and patch when summary metadata is unchanged", () => {
|
||||
const prev = [diff({ summarized: false, before: "old\n", after: "new\n", patch: "@@ -1 +1 @@\n-old\n+new\n" })]
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -148,6 +148,18 @@ describe("Agent Manager per-project session discovery", () => {
|
||||
expect(out.find((s) => s.id === "ses-root")?.title).toBe("Session ses-root")
|
||||
})
|
||||
|
||||
it("does not expose child sessions as project sidebar sessions", async () => {
|
||||
const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" }
|
||||
const ctx = makeContext(ROOT, fakeState([wt]))
|
||||
const rootSession = mkSession("ses-root", WT_PATH)
|
||||
const child = { ...mkSession("ses-child", WT_PATH), parentID: rootSession.id }
|
||||
const { listing } = recordingListing({ [ROOT]: [], [WT_PATH]: [child, rootSession] })
|
||||
|
||||
const out = await collectProjectSessions(ctx, listing)
|
||||
|
||||
expect(out.map((s) => s.id)).toEqual(["ses-root"])
|
||||
})
|
||||
|
||||
it("does not list or include sessions from unrelated-project directories", async () => {
|
||||
const wt: Worktree = { id: "wt-1", branch: "fix", path: WT_PATH, parentBranch: "main", createdAt: "" }
|
||||
const ctx = makeContext(ROOT, fakeState([wt]))
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { rootSessions } from "../../webview-ui/agent-manager/project/session-filter"
|
||||
import type { ProjectSessionInfo } from "../../webview-ui/src/types/messages"
|
||||
|
||||
const session = (id: string, worktreeId: string | null, parentID: string | null): ProjectSessionInfo => ({
|
||||
id,
|
||||
worktreeId,
|
||||
parentID,
|
||||
title: id,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
})
|
||||
|
||||
describe("rootSessions", () => {
|
||||
it("ignores child sessions when selecting a worktree label", () => {
|
||||
const sessions = [session("child", "wt-1", "root"), session("root", "wt-1", null), session("other", "wt-2", null)]
|
||||
|
||||
expect(rootSessions(sessions, "wt-1").map((item) => item.id)).toEqual(["root"])
|
||||
})
|
||||
|
||||
it("filters subagents from the local session list too", () => {
|
||||
const sessions = [session("child", null, "root"), session("root", null, null)]
|
||||
|
||||
expect(rootSessions(sessions, null).map((item) => item.id)).toEqual(["root"])
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
versionedName,
|
||||
} from "../../src/agent-manager/branch-name"
|
||||
import { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
|
||||
import type { PRInfo } from "../../src/agent-manager/git-import"
|
||||
import simpleGit from "simple-git"
|
||||
|
||||
// Each test gets its own temp directory -- no shared state, safe to run in parallel.
|
||||
@@ -1138,6 +1139,92 @@ describe("WorktreeManager.createWorktree advanced", () => {
|
||||
const devParams = await git.log(["-1"])
|
||||
expect(headParams.latest?.hash).toBe(devParams.latest?.hash)
|
||||
})
|
||||
|
||||
it("creates from a base branch excluded by the remote fetch refspec", async () => {
|
||||
const { clone } = await createTempRepoWithOrigin()
|
||||
const git = simpleGit(clone)
|
||||
await git.checkoutLocalBranch("topic")
|
||||
await fs.writeFile(path.join(clone, "topic.txt"), "topic")
|
||||
await git.add(".")
|
||||
await git.commit("topic commit")
|
||||
await git.push("origin", "topic")
|
||||
await git.checkout("main")
|
||||
|
||||
await git.raw(["config", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main"])
|
||||
await git.raw(["update-ref", "-d", "refs/remotes/origin/topic"])
|
||||
|
||||
const result = await createManager(clone).createWorktree({ baseBranch: "topic", prompt: "from topic" })
|
||||
const remoteHead = (await git.revparse(["refs/remotes/origin/topic"])).trim()
|
||||
const worktreeHead = (await simpleGit(result.path).revparse(["HEAD"])).trim()
|
||||
|
||||
expect(worktreeHead).toBe(remoteHead)
|
||||
expect(result.parentBranch).toBe("topic")
|
||||
})
|
||||
|
||||
it("creates from a same-repository PR branch excluded by the remote fetch refspec", async () => {
|
||||
const { clone } = await createTempRepoWithOrigin()
|
||||
const git = simpleGit(clone)
|
||||
await git.checkoutLocalBranch("topic")
|
||||
await fs.writeFile(path.join(clone, "topic.txt"), "topic")
|
||||
await git.add(".")
|
||||
await git.commit("topic commit")
|
||||
await git.push("origin", "topic")
|
||||
await git.checkout("main")
|
||||
await git.raw(["config", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main"])
|
||||
await git.raw(["update-ref", "-d", "refs/remotes/origin/topic"])
|
||||
await git.branch(["-D", "topic"])
|
||||
|
||||
const manager = createManager(clone)
|
||||
const internal = manager as unknown as {
|
||||
fetchPRInfo: (parsed: { owner: string; repo: string; number: number }) => Promise<PRInfo>
|
||||
}
|
||||
internal.fetchPRInfo = async () => ({
|
||||
headRefName: "topic",
|
||||
isCrossRepository: false,
|
||||
title: "Topic PR",
|
||||
})
|
||||
|
||||
const result = await manager.createFromPR("https://github.com/org/repo/pull/1")
|
||||
const remoteHead = (await git.revparse(["refs/remotes/origin/topic"])).trim()
|
||||
const worktreeHead = (await simpleGit(result.path).revparse(["HEAD"])).trim()
|
||||
|
||||
expect(worktreeHead).toBe(remoteHead)
|
||||
expect(result.parentBranch).toBe("topic")
|
||||
})
|
||||
|
||||
it("does not track a deleted PR source branch when using the pull ref fallback", async () => {
|
||||
const { bare, clone } = await createTempRepoWithOrigin()
|
||||
const git = simpleGit(clone)
|
||||
await git.checkoutLocalBranch("topic")
|
||||
await fs.writeFile(path.join(clone, "topic.txt"), "topic")
|
||||
await git.add(".")
|
||||
await git.commit("topic commit")
|
||||
await git.push("origin", "topic")
|
||||
const head = (await git.revparse(["topic"])).trim()
|
||||
await git.checkout("main")
|
||||
await git.raw(["config", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main"])
|
||||
await git.raw(["update-ref", "-d", "refs/remotes/origin/topic"])
|
||||
gitExec(["git", "--git-dir", bare, "update-ref", "refs/pull/1/head", head])
|
||||
gitExec(["git", "--git-dir", bare, "update-ref", "-d", "refs/heads/topic"])
|
||||
await git.branch(["-D", "topic"])
|
||||
|
||||
const manager = createManager(clone)
|
||||
const internal = manager as unknown as {
|
||||
fetchPRInfo: (parsed: { owner: string; repo: string; number: number }) => Promise<PRInfo>
|
||||
}
|
||||
internal.fetchPRInfo = async () => ({
|
||||
headRefName: "topic",
|
||||
isCrossRepository: false,
|
||||
title: "Topic PR",
|
||||
})
|
||||
|
||||
const result = await manager.createFromPR("https://github.com/org/repo/pull/1")
|
||||
const upstream = await git.raw(["config", "--get", "branch.topic.remote"]).catch(() => "")
|
||||
const worktreeHead = (await simpleGit(result.path).revparse(["HEAD"])).trim()
|
||||
|
||||
expect(worktreeHead).toBe(head)
|
||||
expect(upstream.trim()).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -178,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"
|
||||
@@ -306,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 = () => {
|
||||
@@ -321,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()
|
||||
@@ -1214,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
|
||||
@@ -1263,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
|
||||
@@ -2157,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()) {
|
||||
@@ -2587,7 +2615,7 @@ const AgentManagerContent: Component = () => {
|
||||
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` }}
|
||||
@@ -2654,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>
|
||||
)
|
||||
@@ -60,7 +60,7 @@ import { VirtualDiffList } from "../diff-viewer/VirtualDiffList"
|
||||
import { treeOrder } from "../diff-viewer/file-tree-utils"
|
||||
import { isMarkdownFile, MarkdownDiffView } from "../diff-viewer/MarkdownDiffView"
|
||||
import { ImageDiffView } from "../diff-viewer/ImageDiffView"
|
||||
import { createDiffRows } from "../diff-viewer/diff-state"
|
||||
import { createDiffRows, diffSizeKey } from "../diff-viewer/diff-state"
|
||||
import { createDiffRequests } from "../diff-viewer/diff-requests"
|
||||
|
||||
// --- Data model ---
|
||||
@@ -728,6 +728,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
|
||||
after={{ name: diff.file, contents: diff.after }}
|
||||
patch={diff.patch}
|
||||
diffStyle={props.diffStyle ?? "unified"}
|
||||
sizeKey={diffSizeKey(props.sessionKey, diff, props.diffStyle ?? "unified")}
|
||||
virtualized={shouldVirtualizeDiff(diff)}
|
||||
annotations={annotationsForFile(diff.file)}
|
||||
renderAnnotation={buildAnnotation}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { ConstrainDragXAxis } from "./constrain-drag-x"
|
||||
import { createProjectStore, type ProjectStore } from "./project/store"
|
||||
import { randomColor } from "./section-colors"
|
||||
import { projectSidebarOrder, projectWorktreeRow } from "./project-local-navigation"
|
||||
import { rootSessions } from "./project/session-filter"
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
|
||||
|
||||
@@ -94,8 +95,7 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
|
||||
pendingTimer = setTimeout(() => setPending(undefined), 2500)
|
||||
}
|
||||
const state = () => props.state
|
||||
const sessions = (worktreeId: string | null) =>
|
||||
(props.sessions ?? []).filter((item) => item.worktreeId === worktreeId)
|
||||
const sessions = (worktreeId: string | null) => rootSessions(props.sessions ?? [], worktreeId)
|
||||
const active = () => props.selectedProject === props.project.id
|
||||
const runs = () => store.runStatuses()
|
||||
const sections = () => store.sections()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
@@ -1514,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;
|
||||
}
|
||||
@@ -4841,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;
|
||||
@@ -4850,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);
|
||||
}
|
||||
|
||||
@@ -4978,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;
|
||||
@@ -5001,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;
|
||||
@@ -5020,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;
|
||||
}
|
||||
|
||||
@@ -5054,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)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ProjectSessionInfo } from "../../src/types/messages"
|
||||
import { isKnownRootSession } from "../navigate"
|
||||
|
||||
export function rootSessions(sessions: ProjectSessionInfo[], worktreeId: string | null): ProjectSessionInfo[] {
|
||||
return sessions.filter((session) => session.worktreeId === worktreeId && isKnownRootSession(session))
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -61,7 +61,7 @@ import { DiffEndMarker } from "./DiffEndMarker"
|
||||
import { VirtualDiffList } from "./VirtualDiffList"
|
||||
import { isMarkdownFile, MarkdownDiffView } from "./MarkdownDiffView"
|
||||
import { ImageDiffView } from "./ImageDiffView"
|
||||
import { createDiffRows } from "./diff-state"
|
||||
import { createDiffRows, diffSizeKey } from "./diff-state"
|
||||
import { createDiffRequests } from "./diff-requests"
|
||||
|
||||
type DiffStyle = "unified" | "split"
|
||||
@@ -802,6 +802,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
|
||||
after={{ name: diff.file, contents: diff.after }}
|
||||
patch={diff.patch}
|
||||
diffStyle={props.diffStyle}
|
||||
sizeKey={diffSizeKey(props.sessionKey, diff, props.diffStyle)}
|
||||
virtualized={shouldVirtualizeDiff(diff)}
|
||||
annotations={annotationsForFile(diff.file)}
|
||||
renderAnnotation={buildAnnotation}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import type { WorktreeFileDiff } from "../src/types/messages"
|
||||
|
||||
const sizeKeys = new WeakMap<
|
||||
WorktreeFileDiff,
|
||||
{
|
||||
context: string | undefined
|
||||
style: string
|
||||
patch: string | undefined
|
||||
before: string
|
||||
after: string
|
||||
key: object
|
||||
}
|
||||
>()
|
||||
|
||||
export function sameDiffMeta(left: WorktreeFileDiff, right: WorktreeFileDiff) {
|
||||
return (
|
||||
left.file === right.file &&
|
||||
@@ -20,6 +32,23 @@ export function diffToken(diff: WorktreeFileDiff) {
|
||||
return diff.stamp ?? parts.join(":")
|
||||
}
|
||||
|
||||
export function diffSizeKey(context: string | undefined, diff: WorktreeFileDiff, style: string) {
|
||||
const cached = sizeKeys.get(diff)
|
||||
if (
|
||||
cached &&
|
||||
cached.context === context &&
|
||||
cached.style === style &&
|
||||
cached.patch === diff.patch &&
|
||||
cached.before === diff.before &&
|
||||
cached.after === diff.after
|
||||
)
|
||||
return cached.key
|
||||
|
||||
const key = {}
|
||||
sizeKeys.set(diff, { context, style, patch: diff.patch, before: diff.before, after: diff.after, key })
|
||||
return key
|
||||
}
|
||||
|
||||
// Keep each rendered row mounted while live detail refreshes replace its data.
|
||||
// Otherwise Solid's keyed <For> remounts the row and deferred rendering swaps a
|
||||
// previously rendered diff above the viewport for a short placeholder.
|
||||
|
||||
@@ -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.
|
||||
@@ -287,50 +289,58 @@ export const PermissionDock: Component<{
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={skillShellCommands().length > 0}
|
||||
fallback={
|
||||
<>
|
||||
<Show when={cmdDescription()}>{(desc) => <div data-slot="permission-hint">{desc()}</div>}</Show>
|
||||
<Show when={command()}>
|
||||
{(cmd) => <PermissionCommand command={cmd()} plain={props.request.args.heredoc === true} />}
|
||||
</Show>
|
||||
{/* Everything above the buttons scrolls: a long command or a large diff must never
|
||||
push Allow/Deny out of the clipped chat view. */}
|
||||
<div data-slot="permission-scroll">
|
||||
{/* Pierre's virtualizer uses the scroll root's first child as its content
|
||||
container, so keep all variable-height permission content in one wrapper. */}
|
||||
<div data-slot="permission-scroll-content">
|
||||
<Show
|
||||
when={skillShellCommands().length > 0}
|
||||
fallback={
|
||||
<>
|
||||
<Show when={cmdDescription()}>{(desc) => <div data-slot="permission-hint">{desc()}</div>}</Show>
|
||||
<Show when={command()}>
|
||||
{(cmd) => <PermissionCommand command={cmd()} plain={props.request.args.heredoc === true} />}
|
||||
</Show>
|
||||
|
||||
{(() => {
|
||||
const desc = description()
|
||||
if (!desc)
|
||||
return !command() && toolDescription() ? (
|
||||
<div data-slot="permission-hint">{toolDescription()}</div>
|
||||
) : null
|
||||
if (desc.kind === "single")
|
||||
return (
|
||||
<div
|
||||
data-slot="permission-hint"
|
||||
data-wrap={external() ? "" : undefined}
|
||||
title={external() ? desc.text : undefined}
|
||||
>
|
||||
{desc.text}
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div data-slot="permission-patterns">
|
||||
<span data-slot="permission-patterns-title">{desc.title}</span>
|
||||
<For each={desc.paths}>{(path) => <code data-slot="permission-pattern">{path}</code>}</For>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Verbatim commands (args.commands), control-char/bidi-escaped so the displayed command matches execution. */}
|
||||
<For each={skillShellCommands()}>{(cmd) => <PermissionCommand command={displaySkillCommand(cmd)} />}</For>
|
||||
</Show>
|
||||
{(() => {
|
||||
const desc = description()
|
||||
if (!desc)
|
||||
return !command() && toolDescription() ? (
|
||||
<div data-slot="permission-hint">{toolDescription()}</div>
|
||||
) : null
|
||||
if (desc.kind === "single")
|
||||
return (
|
||||
<div
|
||||
data-slot="permission-hint"
|
||||
data-wrap={external() ? "" : undefined}
|
||||
title={external() ? desc.text : undefined}
|
||||
>
|
||||
{desc.text}
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div data-slot="permission-patterns">
|
||||
<span data-slot="permission-patterns-title">{desc.title}</span>
|
||||
<For each={desc.paths}>{(path) => <code data-slot="permission-pattern">{path}</code>}</For>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Verbatim commands (args.commands), control-char/bidi-escaped so the displayed command matches execution. */}
|
||||
<For each={skillShellCommands()}>{(cmd) => <PermissionCommand command={displaySkillCommand(cmd)} />}</For>
|
||||
</Show>
|
||||
|
||||
<Show when={diffs().length > 0}>
|
||||
<div data-slot="permission-diffs" data-count={diffs().length}>
|
||||
<For each={diffs()}>{(diff) => <PermissionDiff filediff={diff} />}</For>
|
||||
<Show when={diffs().length > 0}>
|
||||
<div data-slot="permission-diffs" data-count={diffs().length}>
|
||||
<For each={diffs()}>{(diff) => <PermissionDiff filediff={diff} />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div data-slot="permission-actions">
|
||||
<Button
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -776,7 +776,6 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
buildTriggerLabel(
|
||||
activeModel()?.name,
|
||||
activeModel()?.providerID,
|
||||
activeModel()?.providerName,
|
||||
props.value,
|
||||
props.allowClear ?? false,
|
||||
props.clearLabel ?? "",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -286,11 +286,13 @@ interface SessionContextValue {
|
||||
clearCurrentSession: () => void
|
||||
loadSessions: () => void
|
||||
loadOlderMessages: () => boolean
|
||||
selectSession: (id: string) => void
|
||||
selectSession: (id: string, options?: { focus?: boolean }) => void
|
||||
releaseSession: (id: string) => void
|
||||
deleteSession: (id: string) => void
|
||||
renameSession: (id: string, title: string) => void
|
||||
exportSessionTranscript: (id: string) => void
|
||||
syncSession: (sessionID: string) => void
|
||||
syncSession: (sessionID: string, parentSessionID?: string, scope?: "task" | "inspector") => void
|
||||
unsyncSession: (sessionID: string, scope?: "task" | "inspector") => void
|
||||
|
||||
// Cloud session preview
|
||||
cloudPreviewId: Accessor<string | null>
|
||||
@@ -2598,9 +2600,9 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
|
||||
// Session whose message fetch was deferred because the backend was offline at
|
||||
// selection time. Replayed by the reconnect effect below.
|
||||
let deferredFetch: string | undefined
|
||||
let deferredFetch: { id: string; focus: boolean } | undefined
|
||||
|
||||
function selectSession(id: string) {
|
||||
function selectSession(id: string, options: { focus?: boolean } = {}) {
|
||||
// Cloud preview sessions use a separate keyed path (selectCloudSession).
|
||||
if (id.startsWith("cloud:")) {
|
||||
console.warn("[Kilo New] Cannot select cloud preview session via selectSession")
|
||||
@@ -2621,15 +2623,26 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
// load message is what re-focuses the backend (focusSession, contextSessionID,
|
||||
// SSE tracking, active worktree) and runs the reconcile self-heal, so skipping
|
||||
// it would leave the extension focused on the previously selected session.
|
||||
const focus = options.focus !== false
|
||||
if (!server.isConnected()) {
|
||||
deferredFetch = id
|
||||
deferredFetch = { id, focus }
|
||||
return
|
||||
}
|
||||
deferredFetch = undefined
|
||||
loadFocusedMessages(id, ready)
|
||||
loadFocusedMessages(id, ready, focus)
|
||||
}
|
||||
|
||||
function loadFocusedMessages(id: string, ready: boolean) {
|
||||
function loadFocusedMessages(id: string, ready: boolean, focus = true) {
|
||||
if (!focus) {
|
||||
vscode.postMessage({
|
||||
type: "loadMessages",
|
||||
sessionID: id,
|
||||
mode: "replace",
|
||||
focus: false,
|
||||
limit: MESSAGE_PAGE_LIMIT,
|
||||
})
|
||||
return
|
||||
}
|
||||
vscode.postMessage(
|
||||
ready
|
||||
? { type: "loadMessages", sessionID: id, mode: "focus" }
|
||||
@@ -2644,10 +2657,10 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
createEffect(
|
||||
on(server.isConnected, (connected) => {
|
||||
if (!connected) return
|
||||
const id = deferredFetch
|
||||
const pending = deferredFetch
|
||||
deferredFetch = undefined
|
||||
if (!id || id !== currentSessionID()) return
|
||||
loadFocusedMessages(id, loaded().has(id))
|
||||
if (!pending || pending.id !== currentSessionID()) return
|
||||
loadFocusedMessages(pending.id, loaded().has(pending.id), pending.focus)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2835,8 +2848,12 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
vscode.postMessage({ type: "deleteMessage", sessionID, messageID })
|
||||
}
|
||||
|
||||
function syncSession(sessionID: string) {
|
||||
vscode.postMessage({ type: "syncSession", sessionID, parentSessionID: currentSessionID() })
|
||||
function syncSession(sessionID: string, parentSessionID = currentSessionID(), scope: "task" | "inspector" = "task") {
|
||||
vscode.postMessage({ type: "syncSession", sessionID, parentSessionID, scope })
|
||||
}
|
||||
|
||||
function unsyncSession(sessionID: string, scope: "task" | "inspector" = "task") {
|
||||
vscode.postMessage({ type: "unsyncSession", sessionID, scope })
|
||||
}
|
||||
|
||||
const todos = () => {
|
||||
@@ -3031,10 +3048,12 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
loadSessions,
|
||||
loadOlderMessages,
|
||||
selectSession,
|
||||
releaseSession: handleSessionDeleted,
|
||||
deleteSession,
|
||||
renameSession,
|
||||
exportSessionTranscript,
|
||||
syncSession,
|
||||
unsyncSession,
|
||||
cloudPreviewId,
|
||||
selectCloudSession,
|
||||
draftSessionID,
|
||||
|
||||
+1
@@ -265,6 +265,7 @@ export const dict = {
|
||||
"notification.permission.title": "مطلوب إذن",
|
||||
"notification.permission.titleSubagent": "مطلوب إذن (وكيل فرعي)",
|
||||
"notification.permission.titleSkillShell": "هل تريد تشغيل أوامر الصدفة من المهارة «{{skill}}»؟",
|
||||
"notification.permission.titleSandboxEscalation": "السماح بعملية Git خارج البيئة المعزولة؟",
|
||||
"ui.permission.manageAutoApprove": "إدارة قواعد الموافقة التلقائية",
|
||||
"ui.permission.doomLoop.prompt": "تم اكتشاف حلقة محتملة في أداة {{tool}}. هل تريد متابعة التشغيل؟",
|
||||
"ui.permission.doomLoop.rule": "متابعة استدعاءات {{tool}}",
|
||||
|
||||
+1
@@ -275,6 +275,7 @@ export const dict = {
|
||||
"notification.permission.title": "Permissão necessária",
|
||||
"notification.permission.titleSubagent": "Permissão necessária (subagente)",
|
||||
"notification.permission.titleSkillShell": "Executar comandos de shell da skill “{{skill}}”?",
|
||||
"notification.permission.titleSandboxEscalation": "Permitir operação do Git fora da sandbox?",
|
||||
"ui.permission.manageAutoApprove": "Gerenciar regras de aprovação automática",
|
||||
"ui.permission.doomLoop.prompt": "Possível loop detectado na ferramenta {{tool}}. Continuar executando?",
|
||||
"ui.permission.doomLoop.rule": "Continuar chamadas de {{tool}}",
|
||||
|
||||
+1
@@ -273,6 +273,7 @@ export const dict = {
|
||||
"notification.permission.title": "Potrebna dozvola",
|
||||
"notification.permission.titleSubagent": "Potrebna dozvola (podagent)",
|
||||
"notification.permission.titleSkillShell": "Pokrenuti shell komande iz vještine „{{skill}}”?",
|
||||
"notification.permission.titleSandboxEscalation": "Dozvoliti Git operaciju izvan sandboxa?",
|
||||
"ui.permission.manageAutoApprove": "Upravljanje pravilima automatskog odobravanja",
|
||||
"ui.permission.doomLoop.prompt": "Otkrivena je moguća petlja za alat {{tool}}. Nastaviti izvršavanje?",
|
||||
"ui.permission.doomLoop.rule": "Nastavi pozive alata {{tool}}",
|
||||
|
||||
+1
@@ -272,6 +272,7 @@ export const dict = {
|
||||
"notification.permission.title": "Tilladelse påkrævet",
|
||||
"notification.permission.titleSubagent": "Tilladelse påkrævet (underagent)",
|
||||
"notification.permission.titleSkillShell": "Kør shell-kommandoer fra færdigheden „{{skill}}“?",
|
||||
"notification.permission.titleSandboxEscalation": "Tillad Git-handling uden for sandkassen?",
|
||||
"ui.permission.manageAutoApprove": "Administrer regler for automatisk godkendelse",
|
||||
"ui.permission.doomLoop.prompt": "Der blev registreret en mulig løkke for værktøjet {{tool}}. Fortsæt kørslen?",
|
||||
"ui.permission.doomLoop.rule": "Fortsæt {{tool}}-kald",
|
||||
|
||||
@@ -281,6 +281,7 @@ export const dict = {
|
||||
"notification.permission.title": "Berechtigung erforderlich",
|
||||
"notification.permission.titleSubagent": "Berechtigung erforderlich (Subagent)",
|
||||
"notification.permission.titleSkillShell": "Shell-Befehle aus dem Skill „{{skill}}“ ausführen?",
|
||||
"notification.permission.titleSandboxEscalation": "Git-Vorgang außerhalb der Sandbox zulassen?",
|
||||
"ui.permission.manageAutoApprove": "Regeln für automatische Genehmigung verwalten",
|
||||
"ui.permission.doomLoop.prompt": "Potenzielle Schleife beim Tool {{tool}} erkannt. Weiter ausführen?",
|
||||
"ui.permission.doomLoop.rule": "{{tool}}-Aufrufe fortsetzen",
|
||||
|
||||
@@ -270,6 +270,7 @@ export const dict = {
|
||||
"notification.permission.title": "Permission required",
|
||||
"notification.permission.titleSubagent": "Permission required (subagent)",
|
||||
"notification.permission.titleSkillShell": 'Run shell commands from skill "{{skill}}"?',
|
||||
"notification.permission.titleSandboxEscalation": "Allow Git operation outside the sandbox?",
|
||||
"ui.permission.manageAutoApprove": "Manage Auto-Approve Rules",
|
||||
"ui.permission.doomLoop.prompt": "Potential loop detected for the {{tool}} tool. Continue running?",
|
||||
"ui.permission.doomLoop.rule": "Continue {{tool}} calls",
|
||||
|
||||
+1
@@ -276,6 +276,7 @@ export const dict = {
|
||||
"notification.permission.title": "Permiso requerido",
|
||||
"notification.permission.titleSubagent": "Permiso requerido (subagente)",
|
||||
"notification.permission.titleSkillShell": "¿Ejecutar comandos de shell de la habilidad «{{skill}}»?",
|
||||
"notification.permission.titleSandboxEscalation": "¿Permitir la operación de Git fuera del entorno aislado?",
|
||||
"ui.permission.manageAutoApprove": "Gestionar reglas de aprobación automática",
|
||||
"ui.permission.doomLoop.prompt": "Se detectó un posible bucle en la herramienta {{tool}}. ¿Continuar ejecutando?",
|
||||
"ui.permission.doomLoop.rule": "Continuar llamadas a {{tool}}",
|
||||
|
||||
+1
@@ -270,6 +270,7 @@ export const dict = {
|
||||
"notification.permission.title": "مجوز لازم است",
|
||||
"notification.permission.titleSubagent": "مجوز مورد نیاز است (زیرعامل)",
|
||||
"notification.permission.titleSkillShell": "دستورهای شل از مهارت «{{skill}}» اجرا شود؟",
|
||||
"notification.permission.titleSandboxEscalation": "اجازه انجام عملیات Git خارج از sandbox داده شود؟",
|
||||
"ui.permission.manageAutoApprove": "مدیریت قوانین تأیید خودکار",
|
||||
"ui.permission.doomLoop.prompt": "حلقه احتمالی برای ابزار {{tool}} شناسایی شد. ادامه میدهید؟",
|
||||
"ui.permission.doomLoop.rule": "ادامه فراخوانیهای {{tool}}",
|
||||
|
||||
+1
@@ -275,6 +275,7 @@ export const dict = {
|
||||
"notification.permission.title": "Permission requise",
|
||||
"notification.permission.titleSubagent": "Permission requise (sous-agent)",
|
||||
"notification.permission.titleSkillShell": "Exécuter les commandes shell de la compétence «\u00a0{{skill}}\u00a0» ?",
|
||||
"notification.permission.titleSandboxEscalation": "Autoriser l’opération Git en dehors du bac à sable ?",
|
||||
"ui.permission.manageAutoApprove": "Gérer les règles d'approbation automatique",
|
||||
"ui.permission.doomLoop.prompt": "Boucle potentielle détectée pour l’outil {{tool}}. Continuer l’exécution ?",
|
||||
"ui.permission.doomLoop.rule": "Continuer les appels à {{tool}}",
|
||||
|
||||
+1
@@ -187,6 +187,7 @@ export const dict = {
|
||||
"notification.permission.title": "Autorizzazione richiesta",
|
||||
"notification.permission.titleSubagent": "Autorizzazione richiesta (sub-agent)",
|
||||
"notification.permission.titleSkillShell": "Eseguire i comandi shell della skill “{{skill}}”?",
|
||||
"notification.permission.titleSandboxEscalation": "Consentire l'operazione Git al di fuori della sandbox?",
|
||||
"ui.permission.manageAutoApprove": "Gestisci regole approvazione automatica",
|
||||
"ui.permission.doomLoop.prompt": "Rilevato un potenziale ciclo nello strumento {{tool}}. Continuare l'esecuzione?",
|
||||
"ui.permission.doomLoop.rule": "Continua le chiamate a {{tool}}",
|
||||
|
||||
+1
@@ -272,6 +272,7 @@ export const dict = {
|
||||
"notification.permission.title": "権限が必要です",
|
||||
"notification.permission.titleSubagent": "権限が必要です(サブエージェント)",
|
||||
"notification.permission.titleSkillShell": "スキル「{{skill}}」のシェルコマンドを実行しますか?",
|
||||
"notification.permission.titleSandboxEscalation": "サンドボックス外での Git 操作を許可しますか?",
|
||||
"ui.permission.manageAutoApprove": "自動承認ルールを管理",
|
||||
"ui.permission.doomLoop.prompt": "{{tool}} ツールでループの可能性が検出されました。実行を続行しますか?",
|
||||
"ui.permission.doomLoop.rule": "{{tool}} の呼び出しを続行",
|
||||
|
||||
+1
@@ -273,6 +273,7 @@ export const dict = {
|
||||
"notification.permission.title": "권한 필요",
|
||||
"notification.permission.titleSubagent": "권한 필요 (서브에이전트)",
|
||||
"notification.permission.titleSkillShell": '스킬 "{{skill}}"의 셸 명령을 실행할까요?',
|
||||
"notification.permission.titleSandboxEscalation": "샌드박스 외부에서 Git 작업을 허용할까요?",
|
||||
"ui.permission.manageAutoApprove": "자동 승인 규칙 관리",
|
||||
"ui.permission.doomLoop.prompt": "{{tool}} 도구에서 잠재적인 반복 실행이 감지되었습니다. 계속 실행하시겠습니까?",
|
||||
"ui.permission.doomLoop.rule": "{{tool}} 호출 계속",
|
||||
|
||||
+1
@@ -276,6 +276,7 @@ export const dict = {
|
||||
"notification.permission.title": "Toestemming vereist",
|
||||
"notification.permission.titleSubagent": "Toestemming vereist (subagent)",
|
||||
"notification.permission.titleSkillShell": "Shell-opdrachten uit vaardigheid “{{skill}}” uitvoeren?",
|
||||
"notification.permission.titleSandboxEscalation": "Git-bewerking buiten de sandbox toestaan?",
|
||||
"ui.permission.manageAutoApprove": "Beheer automatisch goedkeuren regels",
|
||||
"ui.permission.doomLoop.prompt": "Mogelijke lus gedetecteerd voor het hulpmiddel {{tool}}. Doorgaan met uitvoeren?",
|
||||
"ui.permission.doomLoop.rule": "Doorgaan met {{tool}}-aanroepen",
|
||||
|
||||
+1
@@ -279,6 +279,7 @@ export const dict = {
|
||||
"notification.permission.title": "Tillatelse påkrevd",
|
||||
"notification.permission.titleSubagent": "Tillatelse påkrevd (underagent)",
|
||||
"notification.permission.titleSkillShell": "Kjøre skallkommandoer fra ferdigheten «{{skill}}»?",
|
||||
"notification.permission.titleSandboxEscalation": "Tillate Git-operasjon utenfor sandkassen?",
|
||||
"ui.permission.manageAutoApprove": "Administrer regler for automatisk godkjenning",
|
||||
"ui.permission.doomLoop.prompt": "Mulig løkke oppdaget for verktøyet {{tool}}. Fortsette kjøringen?",
|
||||
"ui.permission.doomLoop.rule": "Fortsett {{tool}}-kall",
|
||||
|
||||
+1
@@ -273,6 +273,7 @@ export const dict = {
|
||||
"notification.permission.title": "Wymagane uprawnienie",
|
||||
"notification.permission.titleSubagent": "Wymagane uprawnienie (podagent)",
|
||||
"notification.permission.titleSkillShell": "Uruchomić polecenia powłoki z umiejętności „{{skill}}”?",
|
||||
"notification.permission.titleSandboxEscalation": "Zezwolić na operację Git poza piaskownicą?",
|
||||
"ui.permission.manageAutoApprove": "Zarządzaj regułami automatycznego zatwierdzania",
|
||||
"ui.permission.doomLoop.prompt": "Wykryto potencjalną pętlę dla narzędzia {{tool}}. Kontynuować działanie?",
|
||||
"ui.permission.doomLoop.rule": "Kontynuuj wywołania {{tool}}",
|
||||
|
||||
+1
@@ -270,6 +270,7 @@ export const dict = {
|
||||
"notification.permission.title": "Требуется разрешение",
|
||||
"notification.permission.titleSubagent": "Требуется разрешение (субагент)",
|
||||
"notification.permission.titleSkillShell": "Выполнить команды оболочки из навыка «{{skill}}»?",
|
||||
"notification.permission.titleSandboxEscalation": "Разрешить операцию Git за пределами песочницы?",
|
||||
"ui.permission.manageAutoApprove": "Управление правилами автоодобрения",
|
||||
"ui.permission.doomLoop.prompt":
|
||||
"Обнаружен потенциальный цикл при работе инструмента {{tool}}. Продолжить выполнение?",
|
||||
|
||||
+1
@@ -270,6 +270,7 @@ export const dict = {
|
||||
"notification.permission.title": "ต้องการสิทธิ์",
|
||||
"notification.permission.titleSubagent": "ต้องการสิทธิ์ (ตัวแทนย่อย)",
|
||||
"notification.permission.titleSkillShell": 'เรียกใช้คำสั่งเชลล์จากสกิล "{{skill}}" หรือไม่?',
|
||||
"notification.permission.titleSandboxEscalation": "อนุญาตการดำเนินการ Git นอกแซนด์บ็อกซ์หรือไม่?",
|
||||
"ui.permission.manageAutoApprove": "จัดการกฎการอนุมัติอัตโนมัติ",
|
||||
"ui.permission.doomLoop.prompt": "ตรวจพบการวนซ้ำที่อาจเกิดขึ้นในเครื่องมือ {{tool}} ต้องการดำเนินการต่อหรือไม่",
|
||||
"ui.permission.doomLoop.rule": "เรียกใช้ {{tool}} ต่อไป",
|
||||
|
||||
+2
@@ -271,6 +271,8 @@ export const dict = {
|
||||
"notification.permission.title": "İzin gerekli",
|
||||
"notification.permission.titleSubagent": "İzin gerekli (alt ajan)",
|
||||
"notification.permission.titleSkillShell": "“{{skill}}” becerisindeki kabuk komutları çalıştırılsın mı?",
|
||||
"notification.permission.titleSandboxEscalation":
|
||||
"Git işleminin korumalı alan dışında gerçekleştirilmesine izin verilsin mi?",
|
||||
"ui.permission.manageAutoApprove": "Otomatik Onay Kurallarını Yönet",
|
||||
"ui.permission.doomLoop.prompt": "{{tool}} aracında olası bir döngü algılandı. Çalıştırmaya devam edilsin mi?",
|
||||
"ui.permission.doomLoop.rule": "{{tool}} çağrılarına devam et",
|
||||
|
||||
+1
@@ -274,6 +274,7 @@ export const dict = {
|
||||
"notification.permission.title": "Потрібен дозвіл",
|
||||
"notification.permission.titleSubagent": "Потрібен дозвіл (підагент)",
|
||||
"notification.permission.titleSkillShell": "Виконати команди оболонки з навички «{{skill}}»?",
|
||||
"notification.permission.titleSandboxEscalation": "Дозволити операцію Git за межами пісочниці?",
|
||||
"ui.permission.manageAutoApprove": "Керувати правилами автоматичного схвалення",
|
||||
"ui.permission.doomLoop.prompt":
|
||||
"Виявлено потенційний цикл під час роботи інструмента {{tool}}. Продовжити виконання?",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user