mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge remote-tracking branch 'origin/johnnyeric/kilo-opencode-v1.18.0' into johnnyeric/kilo-opencode-v1.18.13
# Conflicts: # packages/opencode/src/provider/transform.ts
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
"@kilocode/kilo-ui": patch
|
||||
---
|
||||
|
||||
Improve custom provider edit dialog layout, make advanced configuration action prominent, and add bulk toggle buttons for reasoning and image modalities across all models.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Exclude ChatGPT subscriptions from explicit prompt cache breakpoints.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Prevent project MCP configs from resolving variable-backed headers or inheriting trusted headers when changing endpoints, while preserving unaffected servers.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"@kilocode/kilo-memory": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Reduce noisy memory timeout warnings and retry transient background consolidation failures once.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@kilocode/kilo-gateway": patch
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Ignore negative pricing entries from model catalogs and handle unpriced models gracefully in UI price formatting.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix Mermaid Copy PNG/SVG in VS Code webviews so they put images on the clipboard instead of failing or copying SVG markup.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep model and provider errors visible in VS Code when chat history refreshes.
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f2349f8c0d7049811702e5b8cbeb5fc2f402bdda8630d1a5210a970a13284197
|
||||
size 3694
|
||||
oid sha256:33ee6eb7070237487d1931f32ac77a2549e18e664b993895e9a9d6619527d0aa
|
||||
size 4063
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f2349f8c0d7049811702e5b8cbeb5fc2f402bdda8630d1a5210a970a13284197
|
||||
size 3694
|
||||
oid sha256:33ee6eb7070237487d1931f32ac77a2549e18e664b993895e9a9d6619527d0aa
|
||||
size 4063
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:72a0a206e978250d41b42f3c0585691a78cbe2635c12d320bcefe61cd938ced5
|
||||
size 280
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:72a0a206e978250d41b42f3c0585691a78cbe2635c12d320bcefe61cd938ced5
|
||||
size 280
|
||||
@@ -76,7 +76,7 @@ type OpenRouterModel = z.infer<typeof openRouterModelSchema>
|
||||
function parseApiPrice(price: string | null | undefined): number | undefined {
|
||||
if (!price) return undefined
|
||||
const parsed = parseFloat(price)
|
||||
if (isNaN(parsed)) return undefined
|
||||
if (isNaN(parsed) || parsed < 0) return undefined
|
||||
return parsed * 1_000_000 // Convert $/token → $/M tokens
|
||||
}
|
||||
|
||||
|
||||
@@ -383,3 +383,66 @@ test("keeps organization catalog errors from silently falling back to personal m
|
||||
expect(calls[0]).toContain("/api/gateway/transcription-models")
|
||||
expect(headers[0]?.get("X-KILOCODE-ORGANIZATIONID")).toBe("org-1")
|
||||
})
|
||||
|
||||
test("omits cost when pricing contains negative values (dynamic/auto-routed pricing)", async () => {
|
||||
const orig = globalThis.fetch
|
||||
stubFetch(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: "openrouter/auto",
|
||||
name: "Auto Router",
|
||||
context_length: 128000,
|
||||
max_completion_tokens: 16384,
|
||||
architecture: {
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
supported_parameters: ["tools"],
|
||||
pricing: {
|
||||
prompt: "-1",
|
||||
completion: "-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "test/fixed-price",
|
||||
name: "Fixed Price Model",
|
||||
context_length: 128000,
|
||||
max_completion_tokens: 16384,
|
||||
architecture: {
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
supported_parameters: ["tools"],
|
||||
pricing: {
|
||||
prompt: "0.000003",
|
||||
completion: "0.000015",
|
||||
input_cache_read: "0.0000003",
|
||||
input_cache_write: "-1",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const result = await fetchKiloModels({})
|
||||
|
||||
;(globalThis as any).fetch = orig
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.models["openrouter/auto"]).toBeDefined()
|
||||
expect(result.models["openrouter/auto"].cost).toBeUndefined()
|
||||
expect(result.models["test/fixed-price"].cost).toEqual({
|
||||
input: 3,
|
||||
output: 15,
|
||||
cache_read: 0.3,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+3
-3
@@ -157,14 +157,14 @@ internal class ModelDetailsPanel(
|
||||
return buildList {
|
||||
item.releaseDate?.let { add(KiloBundle.message("model.picker.details.released") to date(it)) }
|
||||
if (!item.free) {
|
||||
item.cost?.let { cost ->
|
||||
item.cost?.takeIf { it.input > 0.0 || it.output > 0.0 }?.let { cost ->
|
||||
add(KiloBundle.message("model.picker.details.input") to price(cost.input))
|
||||
add(KiloBundle.message("model.picker.details.output") to price(cost.output))
|
||||
add(KiloBundle.message("model.picker.details.cached") to cached(cost.input, cost.cache?.read))
|
||||
add(KiloBundle.message("model.picker.details.average") to price(average(cost.input, cost.output, cost.cache?.read)))
|
||||
} ?: run {
|
||||
item.inputPrice?.let { add(KiloBundle.message("model.picker.details.input") to price(it)) }
|
||||
item.outputPrice?.let { add(KiloBundle.message("model.picker.details.output") to price(it)) }
|
||||
item.inputPrice?.takeIf { it > 0.0 }?.let { add(KiloBundle.message("model.picker.details.input") to price(it)) }
|
||||
item.outputPrice?.takeIf { it > 0.0 }?.let { add(KiloBundle.message("model.picker.details.output") to price(it)) }
|
||||
}
|
||||
}
|
||||
ctx?.let { add(KiloBundle.message("model.picker.details.context") to context(it)) }
|
||||
|
||||
@@ -126,7 +126,7 @@ export namespace MemoryOperations {
|
||||
const adds = input.ops
|
||||
.filter((item): item is Add => item.action === "add")
|
||||
// Redact rejected text too: this filter runs before the secret one, so a rejected op never
|
||||
// reaches it, and skips flow into the persistent decisions audit (/memory/show, TUI).
|
||||
// reaches it, and skips remain visible in the operation result.
|
||||
.filter((op) => {
|
||||
const item = reject(op)
|
||||
if (!item) return true
|
||||
@@ -314,7 +314,7 @@ export namespace MemoryOperations {
|
||||
}
|
||||
}
|
||||
|
||||
async function persist(input: { root: string; state: MemorySchema.State; count: number; removed: number }) {
|
||||
async function persist(input: { root: string; state: MemorySchema.State; count: number }) {
|
||||
const index = await MemoryIndexer.rebuild({ root: input.root, state: input.state })
|
||||
await MemoryFiles.writeState(input.root, {
|
||||
...input.state,
|
||||
@@ -323,7 +323,6 @@ export namespace MemoryOperations {
|
||||
lastOperationCount: input.count,
|
||||
},
|
||||
})
|
||||
await MemoryFiles.append(input.root, `apply ops=${input.count} removed=${input.removed}`)
|
||||
return index
|
||||
}
|
||||
|
||||
@@ -365,9 +364,9 @@ export namespace MemoryOperations {
|
||||
const prepared = prepare({ state, ops: input.ops, max: state.limits.maxLineChars })
|
||||
const removes = input.ops.filter((item): item is Remove => item.action === "remove")
|
||||
const plan = planOps({ docs, inventory, removes, adds: prepared.adds, now: Date.now() })
|
||||
// Commit (IO): write changed documents, then rebuild the index, persist state, and audit.
|
||||
// Commit (IO): write changed documents, then rebuild the index and persist state.
|
||||
await writeDocs({ root: input.root, plan })
|
||||
const index = await persist({ root: input.root, state, count: plan.count, removed: plan.removed })
|
||||
const index = await persist({ root: input.root, state, count: plan.count })
|
||||
return {
|
||||
operationCount: plan.count,
|
||||
added: plan.added,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { MemoryOperations } from "./operations"
|
||||
import { MemoryRedact } from "./redact"
|
||||
import { MemoryShared } from "../recall/shared"
|
||||
import { TRANSIENT } from "../schema"
|
||||
import type { MemoryFiles } from "../storage/store"
|
||||
import type { CaptureSkip } from "./parse"
|
||||
|
||||
export { TRANSIENT }
|
||||
|
||||
export type CaptureSourceItem = {
|
||||
id: string
|
||||
text: string
|
||||
@@ -69,14 +72,75 @@ export function errorReason(err: unknown) {
|
||||
return MemoryShared.brief(MemoryRedact.text(parts.join(" ")), 500)
|
||||
}
|
||||
|
||||
export function guardReason(input: string) {
|
||||
const value = input.toLowerCase()
|
||||
if (/\b(429|rate[_ -]?limit|too many requests)\b/.test(value)) return "rate_limit_guard"
|
||||
if (/\b(insufficient[_ -]?quota|quota exceeded|exceeded your quota|billing|credits?|credit balance)\b/.test(value))
|
||||
const RATE_TEXT = /\b(429|rate[_ -]?limit|too many requests)\b/
|
||||
const QUOTA_TEXT = /\b(insufficient[_ -]?quota|quota exceeded|exceeded your quota|billing|credits?|credit balance)\b/
|
||||
const TIMEOUT_TEXT =
|
||||
/\b(timeouterror|etimedout|deadline[ _-]?exceeded|timed out|(connect|headers|body|gateway)[ _-]?time[ -]?out)\b|^timeout$/
|
||||
const TIMEOUT_CODES = new Set(["ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"])
|
||||
|
||||
/** Structural timeout detection over an error's name/status/code, its message text, and its nested
|
||||
* cause/errors chains (cycle-safe). Message matching is limited to `message` — never the serialized
|
||||
* body/data blobs — so response payloads that merely mention "timeout" cannot classify as transient. */
|
||||
function timedOut(item: unknown, seen = new WeakSet<object>()): boolean {
|
||||
if (!item || typeof item !== "object" || seen.has(item)) return false
|
||||
seen.add(item)
|
||||
const err = item as {
|
||||
name?: unknown
|
||||
code?: unknown
|
||||
status?: unknown
|
||||
statusCode?: unknown
|
||||
message?: unknown
|
||||
cause?: unknown
|
||||
errors?: unknown
|
||||
}
|
||||
if (err.name === "TimeoutError") return true
|
||||
if (err.status === 504 || err.statusCode === 504) return true
|
||||
if (typeof err.code === "string" && TIMEOUT_CODES.has(err.code)) return true
|
||||
if (typeof err.message === "string" && TIMEOUT_TEXT.test(err.message.toLowerCase())) return true
|
||||
if (timedOut(err.cause, seen)) return true
|
||||
return Array.isArray(err.errors) && err.errors.some((entry) => timedOut(entry, seen))
|
||||
}
|
||||
|
||||
function rateGuarded(item: unknown, seen = new WeakSet<object>()): boolean {
|
||||
if (!item || typeof item !== "object" || seen.has(item)) return false
|
||||
seen.add(item)
|
||||
const err = item as {
|
||||
status?: unknown
|
||||
statusCode?: unknown
|
||||
message?: unknown
|
||||
cause?: unknown
|
||||
errors?: unknown
|
||||
}
|
||||
if (err.status === 429 || err.statusCode === 429) return true
|
||||
if (typeof err.message === "string" && RATE_TEXT.test(err.message.toLowerCase())) return true
|
||||
if (rateGuarded(err.cause, seen)) return true
|
||||
return Array.isArray(err.errors) && err.errors.some((entry) => rateGuarded(entry, seen))
|
||||
}
|
||||
|
||||
function quotaGuarded(item: unknown, seen = new WeakSet<object>()): boolean {
|
||||
if (!item || typeof item !== "object" || seen.has(item)) return false
|
||||
seen.add(item)
|
||||
const err = item as {
|
||||
message?: unknown
|
||||
cause?: unknown
|
||||
errors?: unknown
|
||||
}
|
||||
if (typeof err.message === "string" && QUOTA_TEXT.test(err.message.toLowerCase())) return true
|
||||
if (quotaGuarded(err.cause, seen)) return true
|
||||
return Array.isArray(err.errors) && err.errors.some((entry) => quotaGuarded(entry, seen))
|
||||
}
|
||||
|
||||
export function guardReason(input: unknown) {
|
||||
const value = (typeof input === "string" ? input : errorReason(input)).toLowerCase()
|
||||
if (typeof input === "string" ? RATE_TEXT.test(value) : rateGuarded(input) || RATE_TEXT.test(value))
|
||||
return "rate_limit_guard"
|
||||
if (typeof input === "string" ? QUOTA_TEXT.test(value) : quotaGuarded(input) || QUOTA_TEXT.test(value))
|
||||
return "quota_guard"
|
||||
if (typeof input === "string" ? TIMEOUT_TEXT.test(value) : timedOut(input)) return TRANSIENT
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** @deprecated Memory audit persistence was removed. */
|
||||
export function skipped(input: { sessionID: string; reason: string }): MemoryFiles.Decision {
|
||||
return {
|
||||
kind: "typed",
|
||||
@@ -94,6 +158,7 @@ export function skipped(input: { sessionID: string; reason: string }): MemoryFil
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Memory audit persistence was removed. */
|
||||
export function auditOps(ops: MemoryOperations.Op[]) {
|
||||
return MemoryShared.audit(ops)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Cause, Effect } from "effect"
|
||||
import {
|
||||
auditOps,
|
||||
cap,
|
||||
capturePlan,
|
||||
digestPrompt,
|
||||
@@ -18,9 +17,9 @@ import {
|
||||
parseJson,
|
||||
parseOps,
|
||||
salvageTyped,
|
||||
skipped,
|
||||
summarize,
|
||||
summarizeDiffs,
|
||||
TRANSIENT,
|
||||
typedPrompt,
|
||||
usage,
|
||||
verifySkips,
|
||||
@@ -105,11 +104,16 @@ export namespace MemoryCapture {
|
||||
yield* memory.prepare({ root })
|
||||
const state = yield* memory.state({ root })
|
||||
const reported = new Set<string>()
|
||||
const fail = (reason: string) =>
|
||||
const fail = (reason: string, detail?: string) =>
|
||||
Effect.promise(async () => {
|
||||
const safe = MemoryRedact.text(reason)
|
||||
if (reported.has(safe)) return
|
||||
reported.add(safe)
|
||||
if (reason === TRANSIENT)
|
||||
MemoryLog.warn("memory capture transient failure", {
|
||||
reason: safe,
|
||||
detail: MemoryShared.brief(MemoryRedact.text(detail ?? safe), 160),
|
||||
})
|
||||
await MemoryEvents.publish({
|
||||
event: "error",
|
||||
payload: MemoryEvents.status({
|
||||
@@ -123,7 +127,6 @@ export namespace MemoryCapture {
|
||||
})
|
||||
const skip = (reason: string, opts?: { idleFlush?: boolean }) =>
|
||||
Effect.gen(function* () {
|
||||
if (state.enabled) yield* memory.decide({ root, decision: skipped({ sessionID: input.sessionID, reason }) })
|
||||
yield* Effect.promise(() =>
|
||||
MemoryEvents.publish({
|
||||
event: "status",
|
||||
@@ -203,23 +206,6 @@ export namespace MemoryCapture {
|
||||
tokens: 0,
|
||||
fallback: true,
|
||||
})
|
||||
yield* memory.decide({
|
||||
root,
|
||||
decision: {
|
||||
kind: "digest",
|
||||
trigger: "turn-close",
|
||||
sessionID: input.sessionID,
|
||||
result: "fallback",
|
||||
llm: false,
|
||||
parsed: false,
|
||||
fallback: true,
|
||||
reason: input.reason,
|
||||
tokens: 0,
|
||||
operationCount: 1,
|
||||
skippedCount: 0,
|
||||
summary: `session digest fallback on ${input.reason ?? "close"}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
return yield* skip(plan.skipReason, plan.idleFlush ? { idleFlush: true } : undefined)
|
||||
}
|
||||
@@ -232,22 +218,12 @@ export namespace MemoryCapture {
|
||||
|
||||
const model =
|
||||
digestDue || typedCall
|
||||
? yield* Effect.gen(function* () {
|
||||
const resolution = yield* input.model.resolve({
|
||||
? (
|
||||
yield* input.model.resolve({
|
||||
configured: input.memoryModel,
|
||||
session: view.sessionModel,
|
||||
})
|
||||
if (resolution.fallback) {
|
||||
yield* memory.append({
|
||||
root,
|
||||
text: `memory_model_config reason=${MemoryShared.brief(
|
||||
MemoryRedact.text(resolution.fallback.reason),
|
||||
160,
|
||||
)} fallback=1`,
|
||||
})
|
||||
}
|
||||
return resolution.handle
|
||||
})
|
||||
).handle
|
||||
: undefined
|
||||
const digestEffect = digestDue
|
||||
? Effect.gen(function* () {
|
||||
@@ -277,9 +253,8 @@ export namespace MemoryCapture {
|
||||
Effect.gen(function* () {
|
||||
if (signal.aborted) return { ok: false as const, reason: "cancelled" }
|
||||
const raw = errorReason(err)
|
||||
const reason = MemoryRedact.text(guardReason(raw) ?? raw)
|
||||
yield* fail(reason)
|
||||
yield* memory.append({ root, text: `digest error=${MemoryShared.brief(reason, 160)} fallback=1` })
|
||||
const reason = MemoryRedact.text(guardReason(err) ?? raw)
|
||||
yield* fail(reason, raw)
|
||||
return { ok: false as const, reason }
|
||||
}),
|
||||
),
|
||||
@@ -296,14 +271,9 @@ export namespace MemoryCapture {
|
||||
try: () => parseJson(digestSchema, result.result.text),
|
||||
catch: (error) => error,
|
||||
}).pipe(
|
||||
Effect.catch((err: unknown) =>
|
||||
Effect.catch(() =>
|
||||
Effect.gen(function* () {
|
||||
const reason = MemoryRedact.text(errorReason(err))
|
||||
yield* fail("digest parse_error")
|
||||
yield* memory.append({
|
||||
root,
|
||||
text: `digest parse_error=${MemoryShared.brief(reason, 160)} fallback=1`,
|
||||
})
|
||||
return undefined
|
||||
}),
|
||||
),
|
||||
@@ -402,9 +372,8 @@ export namespace MemoryCapture {
|
||||
Effect.gen(function* () {
|
||||
if (signal.aborted) return { ok: false as const, reason: "cancelled" }
|
||||
const raw = errorReason(err)
|
||||
const reason = MemoryRedact.text(guardReason(raw) ?? raw)
|
||||
yield* fail(reason)
|
||||
yield* memory.append({ root, text: `consolidate error=${MemoryShared.brief(reason, 160)}` })
|
||||
const reason = MemoryRedact.text(guardReason(err) ?? raw)
|
||||
yield* fail(reason, raw)
|
||||
return { ok: false as const, reason }
|
||||
}),
|
||||
),
|
||||
@@ -423,11 +392,9 @@ export namespace MemoryCapture {
|
||||
try: () => salvageTyped(result.result.text),
|
||||
catch: (error) => error,
|
||||
}).pipe(
|
||||
Effect.catch((err: unknown) =>
|
||||
Effect.catch(() =>
|
||||
Effect.gen(function* () {
|
||||
const reason = MemoryRedact.text(errorReason(err))
|
||||
yield* fail("consolidate parse_error")
|
||||
yield* memory.append({ root, text: `consolidate parse_error=${MemoryShared.brief(reason, 160)}` })
|
||||
return undefined
|
||||
}),
|
||||
),
|
||||
@@ -475,30 +442,6 @@ export namespace MemoryCapture {
|
||||
fallback: Boolean(digest.reason),
|
||||
})
|
||||
}
|
||||
if (digestDue) {
|
||||
yield* memory.decide({
|
||||
root,
|
||||
decision: {
|
||||
kind: "digest",
|
||||
trigger: "turn-close",
|
||||
sessionID: input.sessionID,
|
||||
result: digest.reason ? "fallback" : digest.summary ? "saved" : "skipped",
|
||||
llm: true,
|
||||
parsed: Boolean(digest.summary && !digest.reason),
|
||||
fallback: Boolean(digest.reason),
|
||||
reason: digest.reason,
|
||||
tokens: digest.tokens,
|
||||
operationCount: digest.summary ? 1 : 0,
|
||||
skippedCount: digest.summary ? 0 : 1,
|
||||
summary: digest.reason
|
||||
? `session digest used fallback after ${digest.reason}`
|
||||
: digest.summary
|
||||
? "session digest saved"
|
||||
: "session digest skipped",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Apply adds only: a same-key add supersedes/updates an existing fact in place. reconcile also
|
||||
// surfaces exact-key auto-removes, but V0 keeps hard removes explicit-only — auto-capture never
|
||||
// deletes memory it merely paraphrased (or wrongly flags), so reconciled.removes is not applied.
|
||||
@@ -506,39 +449,11 @@ export namespace MemoryCapture {
|
||||
const ops = reconciled.ops.slice(0, state.capture.maxOpsPerRun)
|
||||
const project =
|
||||
ops.length > 0 ? yield* memory.apply({ root, ops, trigger: "turn-close", tokens: generated.tokens }) : undefined
|
||||
// Apply-time skips (content gate + secret, both redacted at creation) surface in the typed audit
|
||||
// record alongside the model's own declared skips.
|
||||
const applied: CaptureSkip[] = [...generated.skipped, ...(project?.skipped ?? [])]
|
||||
const count = project?.operationCount ?? 0
|
||||
if (typedCall) {
|
||||
yield* memory.decide({
|
||||
root,
|
||||
decision: {
|
||||
kind: "typed",
|
||||
trigger: "turn-close",
|
||||
sessionID: input.sessionID,
|
||||
result: generated.fallback ? "fallback" : count > 0 ? "saved" : "skipped",
|
||||
llm: true,
|
||||
parsed: !generated.fallback,
|
||||
fallback: generated.fallback,
|
||||
reason: generated.reason,
|
||||
tokens: generated.tokens,
|
||||
operationCount: count,
|
||||
skippedCount: applied.length,
|
||||
skipped: applied,
|
||||
operations: auditOps(ops),
|
||||
files: [...new Set(ops.flatMap((item) => (item.action === "add" && item.file ? [item.file] : [])))],
|
||||
summary: generated.fallback
|
||||
? `typed consolidation skipped after ${generated.reason ?? "model failure"}`
|
||||
: count > 0
|
||||
? `typed consolidation saved ${count} ops`
|
||||
: `typed consolidation skipped ${applied.length} candidates`,
|
||||
},
|
||||
})
|
||||
}
|
||||
const tokens = digest.tokens + generated.tokens
|
||||
if (!digest.summary && !typedCall && count === 0) return yield* skip("no_ops")
|
||||
if ((digestDue || typedCall || count > 0) && (!typedCall || !generated.fallback)) {
|
||||
if (digestDue || typedCall || count > 0) {
|
||||
yield* memory.commit({
|
||||
root,
|
||||
now,
|
||||
@@ -546,8 +461,7 @@ export namespace MemoryCapture {
|
||||
tokens,
|
||||
count,
|
||||
digest: Boolean(digest.summary),
|
||||
typed: typedCall,
|
||||
skipped: applied,
|
||||
typed: typedCall && !generated.fallback,
|
||||
})
|
||||
}
|
||||
const updated = yield* memory.state({ root })
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Context, Effect, Layer, Semaphore } from "effect"
|
||||
import { skipLine, type CaptureSkip } from "../capture/capture"
|
||||
import type { Memory } from "../memory"
|
||||
import type { MemoryOperations } from "../capture/operations"
|
||||
import { MemoryRecall } from "../recall/recall"
|
||||
@@ -104,7 +103,6 @@ type CommitInput = RootInput & {
|
||||
// shared typed-interval clock (lastTypedConsolidationAt); a digest-only commit must leave it untouched so a
|
||||
// digest in one session cannot throttle another session's typed capture.
|
||||
typed: boolean
|
||||
skipped: CaptureSkip[]
|
||||
cost?: number
|
||||
}
|
||||
|
||||
@@ -156,10 +154,12 @@ export namespace MemoryService {
|
||||
readonly recent: (
|
||||
input: RecentInput,
|
||||
) => Effect.Effect<Awaited<ReturnType<typeof MemoryFiles.recentSessions>>, Failure>
|
||||
/** @deprecated Memory audit persistence was removed. */
|
||||
readonly append: (input: AppendInput) => Effect.Effect<void, Failure>
|
||||
readonly index: (input: RootInput) => Effect.Effect<Index, Failure>
|
||||
readonly commit: (input: CommitInput) => Effect.Effect<void, Failure>
|
||||
readonly recordRecall: (input: RecordRecallInput) => Effect.Effect<void, Failure>
|
||||
/** @deprecated Memory audit persistence was removed. */
|
||||
readonly decide: (input: DecideInput) => Effect.Effect<void, Failure>
|
||||
readonly readSource: (input: ReadSourceInput) => Effect.Effect<string, Failure>
|
||||
readonly turnLock: (sessionID: SessionID) => Semaphore.Semaphore
|
||||
@@ -200,7 +200,7 @@ export namespace MemoryService {
|
||||
return Object.fromEntries(entries) as Sources
|
||||
}),
|
||||
recent: (input) => bridge(() => MemoryFiles.recentSessions(input.root, input.limit, input.max)),
|
||||
append: (input) => bridge(() => MemoryFiles.append(input.root, input.text)),
|
||||
append: () => Effect.void,
|
||||
index: (input) =>
|
||||
bridge(async () => {
|
||||
const text = await MemoryFiles.readIndex(input.root)
|
||||
@@ -219,20 +219,11 @@ export namespace MemoryService {
|
||||
lastSessionSavedAt: input.digest ? input.now : state.stats.lastSessionSavedAt,
|
||||
lastConsolidatedMessageID: input.messageID,
|
||||
lastConsolidationCost: input.cost ?? state.stats.lastConsolidationCost,
|
||||
lastConsolidationTokens: input.tokens,
|
||||
lastOperationCount: input.count,
|
||||
lastConsolidationTokens:
|
||||
input.typed || input.digest ? input.tokens : state.stats.lastConsolidationTokens,
|
||||
lastOperationCount: input.typed ? input.count : state.stats.lastOperationCount,
|
||||
},
|
||||
})
|
||||
const skip = skipLine(input.skipped)
|
||||
await MemoryFiles.append(
|
||||
input.root,
|
||||
[
|
||||
`consolidate trigger=turn-close digest=${input.digest ? 1 : 0} ops=${input.count} tokens=${input.tokens}`,
|
||||
skip,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
)
|
||||
}),
|
||||
),
|
||||
recordRecall: (input) =>
|
||||
@@ -266,7 +257,7 @@ export namespace MemoryService {
|
||||
}),
|
||||
})
|
||||
}),
|
||||
decide: (input) => bridge(() => MemoryFiles.decide(input.root, input.decision)),
|
||||
decide: () => Effect.void,
|
||||
readSource: (input) => bridge(() => MemoryFiles.readSource(input.root, input.file)),
|
||||
// Ref-counted so every acquirer — in-flight or queued behind `withPermits` — shares one
|
||||
// semaphore. Each call must be balanced by exactly one `dropLock`.
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
import { MemoryShared } from "./recall/shared"
|
||||
import type { MemoryOperations } from "./capture/operations"
|
||||
import { MemoryRedact } from "./capture/redact"
|
||||
|
||||
/** Human-facing messages and audit views describing an explicit apply result. */
|
||||
/** Human-facing messages describing an explicit apply result. */
|
||||
export namespace MemoryNotice {
|
||||
export function saved(input: { added: number; removed: number }) {
|
||||
return input.removed > 0 || input.added > 0
|
||||
}
|
||||
|
||||
export function summary(input: { added: number; removed: number; count: number }) {
|
||||
if (input.added > 0 && input.removed > 0) {
|
||||
return `explicit memory operation saved ${input.added} and removed ${input.removed}`
|
||||
}
|
||||
if (input.added > 0) return `explicit memory operation saved ${input.added} ops`
|
||||
if (input.removed > 0) return `explicit memory operation removed ${input.removed} entries`
|
||||
if (input.count > 0) return "explicit memory operation matched no source memory"
|
||||
return "explicit memory operation had no accepted ops"
|
||||
}
|
||||
|
||||
export function message(input: { ops: MemoryOperations.Op[]; added: number; removed: number; count: number }) {
|
||||
const refs = MemoryShared.refs(input.ops)
|
||||
if (input.added > 0 && input.removed > 0) return `Memory updated · ${input.added} saved, ${input.removed} removed`
|
||||
@@ -25,15 +14,4 @@ export namespace MemoryNotice {
|
||||
if (input.removed > 0) return `Memory updated · ${input.removed} removed`
|
||||
return `Memory unchanged · ${input.count} ops`
|
||||
}
|
||||
|
||||
export function skip(input: MemoryOperations.Rejection[]) {
|
||||
return input.map((item) => (item.reason === "out_of_scope" ? { reason: item.reason } : item))
|
||||
}
|
||||
|
||||
export function ops(input: { ops: MemoryOperations.Op[]; skipped: MemoryOperations.Rejection[] }) {
|
||||
const blocked = new Set(input.skipped.filter((item) => item.reason === "out_of_scope").map((item) => item.text))
|
||||
return MemoryShared.audit(
|
||||
input.ops.filter((item) => item.action !== "add" || !blocked.has(MemoryRedact.text(item.text))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { MemorySchema } from "./schema"
|
||||
import { MemoryShared } from "./recall/shared"
|
||||
import { MemoryToken } from "./recall/token"
|
||||
import { MemorySlug } from "./slug"
|
||||
import { MemoryRedact } from "./capture/redact"
|
||||
|
||||
/** Root-bound package facade. External Kilo surfaces should derive root from workspace context first. */
|
||||
export namespace Memory {
|
||||
@@ -97,7 +96,6 @@ export namespace Memory {
|
||||
const state = await MemoryFiles.readState(input.root)
|
||||
const next = { ...state, enabled: false }
|
||||
await MemoryFiles.writeState(input.root, next)
|
||||
await MemoryFiles.append(input.root, `disable ${next.scope} source=command`)
|
||||
return { root: input.root, state: next }
|
||||
})
|
||||
}
|
||||
@@ -124,16 +122,6 @@ export namespace Memory {
|
||||
...(input.settings.verbose === undefined ? {} : { verbose: input.settings.verbose }),
|
||||
}
|
||||
await MemoryFiles.writeState(input.root, next)
|
||||
await MemoryFiles.append(
|
||||
input.root,
|
||||
[
|
||||
`settings ${next.scope}`,
|
||||
input.settings.autoConsolidate === undefined ? "" : `autoConsolidate=${next.autoConsolidate}`,
|
||||
input.settings.verbose === undefined ? "" : `verbose=${next.verbose}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
)
|
||||
return { root: input.root, state: next }
|
||||
})
|
||||
}
|
||||
@@ -216,24 +204,6 @@ export namespace Memory {
|
||||
}
|
||||
const state = await MemoryFiles.readState(input.root)
|
||||
const ok = MemoryNotice.saved({ added: result.added, removed: result.removed })
|
||||
if (trigger === "explicit") {
|
||||
await MemoryFiles.decide(input.root, {
|
||||
kind: "typed",
|
||||
trigger,
|
||||
sessionID: input.sessionID,
|
||||
result: ok ? "saved" : "skipped",
|
||||
llm: false,
|
||||
parsed: true,
|
||||
fallback: false,
|
||||
tokens: input.tokens ?? 0,
|
||||
operationCount: result.operationCount,
|
||||
skippedCount: result.skipped.length || (ok ? 0 : 1),
|
||||
skipped: MemoryNotice.skip(result.skipped),
|
||||
operations: MemoryNotice.ops({ ops: accepted, skipped: result.skipped }),
|
||||
files: MemoryShared.files(accepted),
|
||||
summary: MemoryNotice.summary({ added: result.added, removed: result.removed, count: result.operationCount }),
|
||||
})
|
||||
}
|
||||
return {
|
||||
root: input.root,
|
||||
state,
|
||||
@@ -318,31 +288,6 @@ export namespace Memory {
|
||||
const hits = result?.hits ?? []
|
||||
const files = [...new Set(hits.map((hit) => hit.source))]
|
||||
const topics = [...new Set(hits.flatMap((hit) => (hit.topics?.length ? hit.topics : [hit.kind])))]
|
||||
await MemoryFiles.decide(input.root, {
|
||||
kind: "recall",
|
||||
trigger: "targeted-recall",
|
||||
sessionID: input.sessionID,
|
||||
result: result ? "recalled" : "skipped",
|
||||
llm: false,
|
||||
parsed: false,
|
||||
fallback: false,
|
||||
reason: result ? undefined : "no_matches",
|
||||
query: MemoryShared.brief(MemoryRedact.text(input.query), 240),
|
||||
topics,
|
||||
files,
|
||||
tokens: result?.tokens ?? 0,
|
||||
operationCount: hits.length,
|
||||
skippedCount: result ? 0 : 1,
|
||||
summary: result ? `targeted recall matched ${hits.length} memories` : "targeted recall found no matches",
|
||||
})
|
||||
if (result) {
|
||||
await MemoryFiles.queue(input.root, async () => {
|
||||
await MemoryFiles.append(
|
||||
input.root,
|
||||
`recall session=${input.sessionID ?? ""} hits=${result.hits.length} tokens=${result.tokens} files=${files.join(",")}`,
|
||||
)
|
||||
})
|
||||
}
|
||||
return { root: input.root, state, result, hits, files, topics }
|
||||
}
|
||||
|
||||
@@ -368,10 +313,6 @@ export namespace Memory {
|
||||
})
|
||||
await MemoryFiles.pruneSessions(input.root, state.limits.maxSessionFiles)
|
||||
const index = await MemoryIndexer.rebuild({ root: input.root, state })
|
||||
await MemoryFiles.append(
|
||||
input.root,
|
||||
`session digest session=${input.sessionID} tokens=${input.tokens ?? 0} indexTokens=${index.tokens}`,
|
||||
)
|
||||
return { root: input.root, state, skipped: false as const, index }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -226,7 +226,6 @@ export namespace MemoryIndexer {
|
||||
return MemoryFiles.queue(input.root, async () => {
|
||||
const result = await build(input)
|
||||
await MemoryFiles.writeIndex(input.root, result.text)
|
||||
await MemoryFiles.append(input.root, `regenerate index.kmem bytes=${result.bytes} tokens=${result.tokens}`)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ export namespace MemoryShared {
|
||||
]
|
||||
}
|
||||
|
||||
/** @deprecated Memory audit persistence was removed. */
|
||||
export function audit(ops: MemoryOperations.Op[]) {
|
||||
return ops.map((item) =>
|
||||
item.action === "add"
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/** Wire contract for suppressible background-capture failures: guardReason emits this as the
|
||||
* `memory.error` reason, and every client surface (TUI toast filter, VS Code provider) matches
|
||||
* against this same constant rather than a local literal. Lives here (dependency-free module)
|
||||
* so clients can import it without pulling the capture pipeline into their bundles. */
|
||||
export const TRANSIENT = "transient" as const
|
||||
|
||||
export namespace MemorySchema {
|
||||
export const VERSION = 1
|
||||
export const maxStoredDigestSummary = 4_000
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import z from "zod"
|
||||
import { MemoryFs } from "./fs"
|
||||
|
||||
/** Compatibility facade for the removed memory audit log. */
|
||||
export namespace MemoryAudit {
|
||||
const Log = z
|
||||
.object({
|
||||
kind: z.literal("log"),
|
||||
summary: z.string(),
|
||||
time: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export type Decision =
|
||||
| {
|
||||
kind: "log"
|
||||
@@ -43,18 +33,14 @@ export namespace MemoryAudit {
|
||||
}[]
|
||||
}
|
||||
|
||||
function audit(root: string, input: Decision) {
|
||||
void root
|
||||
void input
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
export async function append(root: string, text: string) {
|
||||
await audit(root, { kind: "log", result: "logged", summary: text })
|
||||
void root
|
||||
void text
|
||||
}
|
||||
|
||||
export async function decide(root: string, input: Decision) {
|
||||
await audit(root, input)
|
||||
void root
|
||||
void input
|
||||
}
|
||||
|
||||
export async function readDecisions(root: string) {
|
||||
@@ -62,24 +48,8 @@ export namespace MemoryAudit {
|
||||
return ""
|
||||
}
|
||||
|
||||
function record(input: string) {
|
||||
try {
|
||||
const data = JSON.parse(input)
|
||||
const parsed = Log.safeParse(data)
|
||||
return parsed.success ? parsed.data : undefined
|
||||
} catch (error) {
|
||||
if (MemoryFs.parse(error)) return undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function readChanges(root: string) {
|
||||
const lines = (await readDecisions(root)).split("\n").flatMap((line) => {
|
||||
const data = record(line)
|
||||
if (!data) return []
|
||||
const time = data.time ?? ""
|
||||
return [`${time} ${data.summary}`.trim()]
|
||||
})
|
||||
return lines.join("\n")
|
||||
void root
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { readdir, rm } from "fs/promises"
|
||||
import path from "path"
|
||||
import { MemoryAudit } from "./audit"
|
||||
import { MemoryFs } from "./fs"
|
||||
import { MemoryMarkdown } from "./markdown"
|
||||
import { MemoryPaths } from "./paths"
|
||||
@@ -19,13 +18,10 @@ export namespace MemoryState {
|
||||
"corrections.md": "# Corrective Memory\n\n## Corrections\n",
|
||||
}
|
||||
|
||||
async function recover(root: string, file: string, error: unknown) {
|
||||
async function recover(root: string, file: string) {
|
||||
await MemoryFs.backup(file)
|
||||
const state = MemorySchema.missing()
|
||||
await writeState(root, state)
|
||||
await MemoryAudit.append(root, `recover state.json error=${MemoryFs.brief(error)}`).catch((err: unknown) =>
|
||||
MemoryFs.warn("failed to audit memory state recovery", { err, root }),
|
||||
)
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -33,14 +29,14 @@ export namespace MemoryState {
|
||||
const file = MemoryPaths.files(root).state
|
||||
const data = await MemoryFs.json(file).catch(async (error: unknown) => {
|
||||
if (MemoryFs.miss(error)) return undefined
|
||||
if (MemoryFs.parse(error)) return recover(root, file, error)
|
||||
if (MemoryFs.parse(error)) return recover(root, file)
|
||||
throw error
|
||||
})
|
||||
if (data === undefined) return MemorySchema.missing()
|
||||
return Promise.resolve()
|
||||
.then(() => MemorySchema.parse(data))
|
||||
.catch((error: unknown) => {
|
||||
if (MemoryFs.parse(error)) return recover(root, file, error)
|
||||
if (MemoryFs.parse(error)) return recover(root, file)
|
||||
throw error
|
||||
})
|
||||
}
|
||||
@@ -199,7 +195,6 @@ export namespace MemoryState {
|
||||
? { ...(await readState(root)), enabled: true, autoInject: true }
|
||||
: { ...MemorySchema.create(), enabled: true }
|
||||
await writeState(root, state)
|
||||
await MemoryAudit.append(root, "enable project source=command")
|
||||
return state
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { MemoryError, type MemoryError as Failure } from "./effect/errors"
|
||||
import { MemoryPaths } from "./effect/paths"
|
||||
import { MemoryService } from "./effect/service"
|
||||
import { MemoryRecall } from "./recall/recall"
|
||||
import { MemoryToken } from "./recall/token"
|
||||
import { MemorySchema } from "./schema"
|
||||
import recallDescription from "./prompts/tool-memory-recall.txt"
|
||||
import saveDescription from "./prompts/tool-memory-save.txt"
|
||||
@@ -115,50 +114,6 @@ export namespace MemoryTool {
|
||||
}
|
||||
}
|
||||
|
||||
function audit(
|
||||
memory: MemoryService.Interface,
|
||||
input: {
|
||||
root: string
|
||||
params: RecallParams
|
||||
current: string
|
||||
hits: MemoryRecall.Hit[]
|
||||
skipped?: string
|
||||
output: string
|
||||
},
|
||||
) {
|
||||
const files = [...new Set(input.hits.map((hit) => hit.source))]
|
||||
const topics = [...new Set(input.hits.flatMap((hit) => (hit.topics?.length ? hit.topics : [hit.kind])))]
|
||||
const query =
|
||||
input.params.query ??
|
||||
(input.params.sessionID
|
||||
? `sessionID=${input.params.sessionID}`
|
||||
: input.params.mode === "digest"
|
||||
? "recent digests"
|
||||
: undefined)
|
||||
return memory.decide({
|
||||
root: input.root,
|
||||
decision: {
|
||||
kind: "recall",
|
||||
trigger: "targeted-recall",
|
||||
sessionID: input.current,
|
||||
result: input.hits.length ? "recalled" : "skipped",
|
||||
llm: false,
|
||||
parsed: false,
|
||||
fallback: false,
|
||||
reason: input.skipped,
|
||||
query,
|
||||
topics,
|
||||
files,
|
||||
tokens: MemoryToken.estimate(input.output),
|
||||
operationCount: input.hits.length,
|
||||
skippedCount: input.hits.length ? 0 : 1,
|
||||
summary: input.hits.length
|
||||
? `memory recall returned ${input.hits.length} ${input.params.mode} hits`
|
||||
: `memory recall found no ${input.params.mode} hits`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function miss(input: { params: RecallParams; current: string }) {
|
||||
const self = input.params.sessionID === input.current
|
||||
if (self && input.params.mode === "digest") {
|
||||
@@ -239,35 +194,6 @@ export namespace MemoryTool {
|
||||
})
|
||||
}
|
||||
|
||||
function catalogAudit(
|
||||
memory: MemoryService.Interface,
|
||||
input: {
|
||||
root: string
|
||||
current: string
|
||||
query: string
|
||||
result: { output: string; count: number; files: string[] }
|
||||
},
|
||||
) {
|
||||
return memory.decide({
|
||||
root: input.root,
|
||||
decision: {
|
||||
kind: "recall",
|
||||
trigger: "targeted-recall",
|
||||
sessionID: input.current,
|
||||
result: input.result.count ? "recalled" : "skipped",
|
||||
llm: false,
|
||||
parsed: false,
|
||||
fallback: false,
|
||||
query: input.query || "all keys",
|
||||
files: input.result.files,
|
||||
tokens: MemoryToken.estimate(input.result.output),
|
||||
operationCount: input.result.count,
|
||||
skippedCount: input.result.count ? 0 : 1,
|
||||
summary: `memory catalog listed ${input.result.count} entries`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function approvalRecall(input: Recall) {
|
||||
return input.ask({
|
||||
permission: "kilo_memory_recall",
|
||||
@@ -285,7 +211,6 @@ export namespace MemoryTool {
|
||||
return Effect.gen(function* () {
|
||||
const result = yield* catalog(input.memory, { root: live.root, query })
|
||||
const safe = { ...result, output: block(result.output) }
|
||||
yield* catalogAudit(input.memory, { root: live.root, current: live.current, query, result: safe })
|
||||
yield* input.memory.recordRecall({ root: live.root, sessionID: live.current, now: Date.now(), count: result.count })
|
||||
return {
|
||||
title: `Kilo memory catalog: ${result.count} entr${result.count === 1 ? "y" : "ies"}`,
|
||||
@@ -296,22 +221,13 @@ export namespace MemoryTool {
|
||||
}
|
||||
|
||||
function recallQuery(input: Recall, live: Live) {
|
||||
return Effect.gen(function* () {
|
||||
const output = "Provide a topic query for typed/search memory recall."
|
||||
yield* audit(input.memory, {
|
||||
root: live.root,
|
||||
params: input.params,
|
||||
current: live.current,
|
||||
hits: [],
|
||||
skipped: "missing_query",
|
||||
output,
|
||||
})
|
||||
return {
|
||||
return Effect.succeed(
|
||||
{
|
||||
title: `Kilo memory ${input.params.mode}: no query`,
|
||||
output,
|
||||
output: "Provide a topic query for typed/search memory recall.",
|
||||
metadata: { sources: [], count: 0 },
|
||||
} satisfies Result
|
||||
})
|
||||
} satisfies Result,
|
||||
)
|
||||
}
|
||||
|
||||
function recallSearch(input: Recall, live: Live, query: string, mode: MemoryRecall.Mode) {
|
||||
@@ -327,15 +243,7 @@ export namespace MemoryTool {
|
||||
limit,
|
||||
})
|
||||
const hits = result?.hits ?? []
|
||||
const self = input.params.sessionID === live.current
|
||||
const skipped =
|
||||
input.params.sessionID && input.params.mode === "digest" && hits.length === 0
|
||||
? self
|
||||
? "current_session_digest"
|
||||
: "missing_session_digest"
|
||||
: undefined
|
||||
const output = hits.length ? result!.block : miss({ params: input.params, current: live.current })
|
||||
yield* audit(input.memory, { root: live.root, params: input.params, current: live.current, hits, skipped, output })
|
||||
yield* input.memory.recordRecall({ root: live.root, sessionID: live.current, now: Date.now(), count: hits.length })
|
||||
|
||||
if (hits.length === 0) {
|
||||
@@ -440,29 +348,8 @@ export namespace MemoryTool {
|
||||
return saved(input)
|
||||
}
|
||||
|
||||
function skip(input: Base & { params: SaveParams }, root: string) {
|
||||
return Effect.gen(function* () {
|
||||
const reason = input.params.reason ?? "out_of_scope"
|
||||
yield* input.memory.decide({
|
||||
root,
|
||||
decision: {
|
||||
kind: "typed",
|
||||
trigger: "explicit",
|
||||
sessionID: input.sessionID,
|
||||
result: "skipped",
|
||||
llm: false,
|
||||
parsed: true,
|
||||
fallback: false,
|
||||
reason,
|
||||
tokens: 0,
|
||||
operationCount: 0,
|
||||
skippedCount: 1,
|
||||
skipped: [{ reason }],
|
||||
summary: `explicit memory save skipped: ${reason}`,
|
||||
},
|
||||
})
|
||||
return skipped({ reason })
|
||||
})
|
||||
function skip(input: Base & { params: SaveParams }) {
|
||||
return Effect.succeed(skipped({ reason: input.params.reason ?? "out_of_scope" }))
|
||||
}
|
||||
|
||||
function forget(input: Save, root: string) {
|
||||
@@ -497,7 +384,7 @@ export namespace MemoryTool {
|
||||
const state = yield* input.memory.state({ root })
|
||||
if (!state.enabled) return disabled()
|
||||
if (input.params.action === "forget") return yield* forget(input, root)
|
||||
if (input.params.action === "skip") return yield* skip(input, root)
|
||||
if (input.params.action === "skip") return yield* skip(input)
|
||||
return yield* write(input, root)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
auditOps,
|
||||
capturePlan,
|
||||
duplicateOps,
|
||||
errorReason,
|
||||
fallbackDigest,
|
||||
guardReason,
|
||||
hasSubstantialDiff,
|
||||
@@ -128,7 +128,7 @@ describe("memory capture parsing", () => {
|
||||
expect(() => salvageTyped(`{"op":"upsert_project_fact","key":"good_one","value":"Keep this fact."}`)).toThrow()
|
||||
})
|
||||
|
||||
test("redacts secrets in salvaged unsupported ops before they reach the audit", () => {
|
||||
test("redacts secrets in salvaged unsupported ops before they reach callers", () => {
|
||||
const parsed = salvageTyped(
|
||||
`{"operations":[{"op":"not_a_real_op","key":"leak","value":"key is sk-abcdefghijklmnopqrstuvwxyz"}],"skipped":[]}`,
|
||||
)
|
||||
@@ -169,17 +169,6 @@ describe("memory capture parsing", () => {
|
||||
expect(JSON.stringify(parsed.skipped)).not.toContain(secret.slice(0, 20))
|
||||
})
|
||||
|
||||
test("redacts remove audit queries before truncating", () => {
|
||||
const secret = "sk-" + "a".repeat(40)
|
||||
const query = "x".repeat(100) + secret
|
||||
const audit = auditOps([{ action: "remove", query }])
|
||||
const text = JSON.stringify(audit)
|
||||
|
||||
expect(text).toContain("[redacted]")
|
||||
expect(text).not.toContain(secret)
|
||||
expect(text).not.toContain(secret.slice(0, 20))
|
||||
})
|
||||
|
||||
test("truncates typed batches beyond the op cap instead of failing", () => {
|
||||
const ops = Array.from(
|
||||
{ length: 20 },
|
||||
@@ -332,6 +321,11 @@ describe("memory capture parsing", () => {
|
||||
input: base,
|
||||
expected: { session: true, digestDue: true, typedCall: true, typedWork: true, skipReason: undefined },
|
||||
},
|
||||
{
|
||||
name: "expected work: a persisted null typed clock does not throttle capture",
|
||||
input: { ...base, lastTypedConsolidationAt: null },
|
||||
expected: { session: true, digestDue: true, typedCall: true, typedWork: true, skipReason: undefined },
|
||||
},
|
||||
{
|
||||
name: "expected idle flush: completed turn inside interval skips now",
|
||||
input: { ...base, priorTime: 900, lastTypedConsolidationAt: 900 },
|
||||
@@ -650,6 +644,70 @@ describe("memory capture parsing", () => {
|
||||
)
|
||||
expect(guardReason("429 too many requests")).toBe("rate_limit_guard")
|
||||
expect(guardReason("billing credits exhausted")).toBe("quota_guard")
|
||||
expect(guardReason("request timed out")).toBe("transient")
|
||||
expect(guardReason("deadline exceeded")).toBe("transient")
|
||||
expect(guardReason("DeadlineExceeded")).toBe("transient")
|
||||
expect(guardReason('cause={"code":"ETIMEDOUT"}')).toBe("transient")
|
||||
expect(guardReason("504 Gateway Timeout")).toBe("transient")
|
||||
expect(guardReason("Connect Timeout Error")).toBe("transient")
|
||||
expect(guardReason("Headers Timeout Error")).toBe("transient")
|
||||
expect(guardReason("Body Timeout Error")).toBe("transient")
|
||||
expect(guardReason("connect_timeout")).toBe("transient")
|
||||
expect(guardReason('status=400 body={"error":"invalid parameter: timeout"}')).toBeUndefined()
|
||||
expect(guardReason("set request timeout to 30000")).toBeUndefined()
|
||||
const timeout = new Error("request aborted")
|
||||
timeout.name = "TimeoutError"
|
||||
expect(guardReason(timeout)).toBe("transient")
|
||||
expect(errorReason(timeout)).toBe("request aborted")
|
||||
expect(guardReason(Object.assign(new Error("request failed"), { status: 504 }))).toBe("transient")
|
||||
expect(
|
||||
guardReason(Object.assign(new Error("request failed"), { cause: { code: "UND_ERR_CONNECT_TIMEOUT" } })),
|
||||
).toBe("transient")
|
||||
expect(
|
||||
guardReason(Object.assign(new Error("request failed"), { cause: { code: "UND_ERR_HEADERS_TIMEOUT" } })),
|
||||
).toBe("transient")
|
||||
expect(guardReason(Object.assign(new Error("request failed"), { cause: { code: "ETIMEDOUT" } }))).toBe(
|
||||
"transient",
|
||||
)
|
||||
expect(
|
||||
guardReason(Object.assign(new Error("failed after 2 attempts"), { errors: [{ statusCode: 504 }] })),
|
||||
).toBe("transient")
|
||||
expect(guardReason(new Error("set request timeout to 30000"))).toBeUndefined()
|
||||
expect(guardReason(new Error("request timed out"))).toBe("transient")
|
||||
expect(guardReason(Object.assign(new Error("request failed"), { cause: new Error("connect timeout") }))).toBe(
|
||||
"transient",
|
||||
)
|
||||
expect(
|
||||
guardReason(
|
||||
Object.assign(new Error("failed after 2 attempts"), {
|
||||
errors: [Object.assign(new Error("rate limit reached"), { status: 429 }), timeout],
|
||||
}),
|
||||
),
|
||||
).toBe("rate_limit_guard")
|
||||
expect(
|
||||
guardReason(
|
||||
Object.assign(new Error("failed after 2 attempts"), {
|
||||
errors: [new Error("insufficient quota available"), timeout],
|
||||
}),
|
||||
),
|
||||
).toBe("quota_guard")
|
||||
expect(
|
||||
guardReason(
|
||||
Object.assign(new Error("request failed"), {
|
||||
cause: Object.assign(new Error("rate limit reached"), { status: 429 }),
|
||||
}),
|
||||
),
|
||||
).toBe("rate_limit_guard")
|
||||
expect(
|
||||
guardReason(
|
||||
Object.assign(new Error("timed out"), {
|
||||
name: "TimeoutError",
|
||||
cause: new Error("billing credit balance exhausted"),
|
||||
}),
|
||||
),
|
||||
).toBe("quota_guard")
|
||||
expect(guardReason("timeout after 429 too many requests")).toBe("rate_limit_guard")
|
||||
expect(guardReason("timeout after quota exceeded")).toBe("quota_guard")
|
||||
})
|
||||
|
||||
test("redacts common secret token shapes", () => {
|
||||
|
||||
@@ -50,6 +50,22 @@ describe("memory core package", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("legacy audit compatibility APIs remain no-op", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
await MemoryFiles.append(t.root, "provider error with sensitive detail")
|
||||
await MemoryFiles.decide(t.root, {
|
||||
kind: "typed",
|
||||
result: "error",
|
||||
reason: "provider error with sensitive detail",
|
||||
})
|
||||
|
||||
expect(await MemoryFiles.readChanges(t.root)).toBe("")
|
||||
expect(await MemoryFiles.readDecisions(t.root)).toBe("")
|
||||
expect(await Bun.file(path.join(t.root, "decisions.jsonl")).exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("prepare removes legacy decisions once from owned memory roots", async () => {
|
||||
await use(async (t) => {
|
||||
await Memory.enable({ root: t.root })
|
||||
|
||||
@@ -7,6 +7,7 @@ import { digestPrompt, typedPrompt } from "../src/capture/capture"
|
||||
import { MemoryCapture } from "../src/effect/capture"
|
||||
import { MemoryEvents } from "../src/effect/events"
|
||||
import { KiloMemory } from "../src/effect/index"
|
||||
import { MemoryLog } from "../src/effect/log"
|
||||
import type { MemoryPorts } from "../src/effect/ports"
|
||||
import { MemoryService } from "../src/effect/service"
|
||||
import { MemoryTimers } from "../src/effect/timers"
|
||||
@@ -49,11 +50,18 @@ function session(turn: MemoryPorts.TurnView | undefined): MemoryPorts.SessionPor
|
||||
|
||||
/** Model port that answers digest/typed calls from canned JSON, keyed by system prompt so it is
|
||||
* order-independent (digest and typed run concurrently). */
|
||||
function model(input: { digest: string; typed: string; fallback?: string; onRun?: (system: string) => void }): MemoryPorts.ModelPort {
|
||||
function model(input: {
|
||||
digest: string
|
||||
typed: string
|
||||
fallback?: string
|
||||
fail?: Error
|
||||
onRun?: (system: string) => void
|
||||
}): MemoryPorts.ModelPort {
|
||||
return {
|
||||
resolve: () => Effect.succeed({ handle: {}, ...(input.fallback ? { fallback: { reason: input.fallback } } : {}) }),
|
||||
run: async ({ system }) => {
|
||||
input.onRun?.(system)
|
||||
if (system === typedPrompt && input.fail) throw input.fail
|
||||
const text = system === digestPrompt ? input.digest : system === typedPrompt ? input.typed : "{}"
|
||||
return { text, usage: USAGE }
|
||||
},
|
||||
@@ -66,6 +74,7 @@ function run(input: {
|
||||
model: MemoryPorts.ModelPort
|
||||
memoryModel?: string
|
||||
reason?: "completed" | "interrupted" | "error"
|
||||
bypassInterval?: boolean
|
||||
}) {
|
||||
return Effect.runPromise(
|
||||
MemoryCapture.turn({
|
||||
@@ -75,6 +84,7 @@ function run(input: {
|
||||
model: input.model,
|
||||
memoryModel: input.memoryModel,
|
||||
reason: input.reason ?? "completed",
|
||||
bypassInterval: input.bypassInterval,
|
||||
}).pipe(Effect.provideService(MemoryService.Service, MemoryService.make())),
|
||||
)
|
||||
}
|
||||
@@ -107,6 +117,121 @@ describe("MemoryCapture (fake ports)", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("typed timeout preserves digest progress without advancing the typed clock", async () => {
|
||||
const t = await tmp()
|
||||
const events: MemoryEvents.Status[] = []
|
||||
const logs: string[] = []
|
||||
try {
|
||||
await KiloMemory.enable({ root: t.root })
|
||||
await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: true } })
|
||||
MemoryLog.setWarn((message, meta) => logs.push(`${message}:${meta?.reason}:${meta?.detail}`))
|
||||
MemoryEvents.setSink((input) => {
|
||||
events.push(input.payload)
|
||||
})
|
||||
|
||||
const result = await run({
|
||||
root: t.root,
|
||||
session: session(view()),
|
||||
model: model({
|
||||
digest: '{"topic":"repo setup","summary":"Explored repo setup commands. Next step: verify memory tests."}',
|
||||
typed: "{}",
|
||||
fail: new DOMException("memory model timed out", "TimeoutError"),
|
||||
}),
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ skipped: false, operationCount: 0 })
|
||||
const state = await MemoryFiles.readState(t.root)
|
||||
expect(state.stats.lastTypedConsolidationAt).toBeNull()
|
||||
expect(state.stats.lastSessionSavedAt).toEqual(expect.any(Number))
|
||||
expect(state.stats.lastConsolidatedMessageID).toBe("msg_assistant")
|
||||
expect(events.find((item) => item.state === "error")?.reason).toBe("transient")
|
||||
expect(logs).toEqual(["memory capture transient failure:transient:memory model timed out"])
|
||||
|
||||
const calls: string[] = []
|
||||
const retry = await run({
|
||||
root: t.root,
|
||||
session: session(view()),
|
||||
model: model({
|
||||
digest: "{}",
|
||||
typed: '{"operations":[],"skipped":[]}',
|
||||
onRun: (system) => calls.push(system),
|
||||
}),
|
||||
bypassInterval: true,
|
||||
})
|
||||
expect(retry).toMatchObject({ skipped: true, reason: "no_new_content" })
|
||||
expect(calls).toEqual([])
|
||||
|
||||
const next = await run({
|
||||
root: t.root,
|
||||
session: session(
|
||||
view({
|
||||
assistant: "Use bun install, then run the package tests and typecheck.",
|
||||
lastAssistantID: "msg_assistant_next",
|
||||
}),
|
||||
),
|
||||
model: model({
|
||||
digest: "{}",
|
||||
typed: '{"operations":[],"skipped":[]}',
|
||||
onRun: (system) => calls.push(system),
|
||||
}),
|
||||
})
|
||||
expect(next).toMatchObject({ skipped: false, operationCount: 0 })
|
||||
expect(calls).toEqual([typedPrompt])
|
||||
const updated = await MemoryFiles.readState(t.root)
|
||||
expect(updated.stats.lastTypedConsolidationAt).toEqual(expect.any(Number))
|
||||
expect(updated.stats.lastConsolidatedMessageID).toBe("msg_assistant_next")
|
||||
} finally {
|
||||
MemoryLog.setWarn(() => {})
|
||||
MemoryEvents.setSink(() => {})
|
||||
await t.done()
|
||||
}
|
||||
})
|
||||
|
||||
test("fallback commit preserves metrics from the prior successful typed consolidation", async () => {
|
||||
const t = await tmp()
|
||||
try {
|
||||
await KiloMemory.enable({ root: t.root })
|
||||
await KiloMemory.configure({ root: t.root, settings: { autoConsolidate: true } })
|
||||
|
||||
const first = await run({
|
||||
root: t.root,
|
||||
session: session(view()),
|
||||
model: model({
|
||||
digest: '{"topic":"repo setup","summary":"Explored repo setup."}',
|
||||
typed:
|
||||
'{"operations":[{"op":"upsert_environment_fact","section":"Commands","key":"test_cmd","value":"bun test"}],"skipped":[]}',
|
||||
}),
|
||||
})
|
||||
expect(first).toMatchObject({ skipped: false, operationCount: 1 })
|
||||
const initial = await MemoryFiles.readState(t.root)
|
||||
expect(initial.stats.lastOperationCount).toBe(1)
|
||||
expect(initial.stats.lastTypedConsolidationAt).toEqual(expect.any(Number))
|
||||
|
||||
const second = await run({
|
||||
root: t.root,
|
||||
session: session(
|
||||
view({
|
||||
assistant: "Investigated a timeout edge case.",
|
||||
lastAssistantID: "msg_assistant_timeout",
|
||||
}),
|
||||
),
|
||||
model: model({
|
||||
digest: '{"topic":"investigation","summary":"Investigated timeouts."}',
|
||||
typed: "{}",
|
||||
fail: new DOMException("memory model timed out", "TimeoutError"),
|
||||
}),
|
||||
bypassInterval: true,
|
||||
})
|
||||
expect(second).toMatchObject({ skipped: false, operationCount: 0 })
|
||||
const preserved = await MemoryFiles.readState(t.root)
|
||||
expect(preserved.stats.lastOperationCount).toBe(1)
|
||||
expect(preserved.stats.lastTypedConsolidationAt).toBe(initial.stats.lastTypedConsolidationAt)
|
||||
expect(preserved.stats.lastConsolidatedMessageID).toBe("msg_assistant_timeout")
|
||||
} finally {
|
||||
await t.done()
|
||||
}
|
||||
})
|
||||
|
||||
test("turn-close skips a secret-like op and applies the rest of the batch", async () => {
|
||||
const t = await tmp()
|
||||
const events: MemoryEvents.Status[] = []
|
||||
|
||||
@@ -12,6 +12,24 @@
|
||||
border: 1px solid var(--border-weak-base);
|
||||
}
|
||||
|
||||
&[data-size="large"] [data-slot="dialog-container"] {
|
||||
width: min(calc(100vw - 32px), 800px);
|
||||
height: min(calc(100vh - 32px), 720px);
|
||||
}
|
||||
|
||||
&[data-size="large"] [data-slot="dialog-content"] {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
&[data-size="x-large"] [data-slot="dialog-container"] {
|
||||
width: min(calc(100vw - 32px), 980px);
|
||||
height: min(calc(100vh - 32px), 800px);
|
||||
}
|
||||
|
||||
&[data-size="x-large"] [data-slot="dialog-content"] {
|
||||
max-width: 980px;
|
||||
}
|
||||
|
||||
[data-slot="dialog-header"] {
|
||||
font-size: var(--kilo-font-size-14);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,18 @@ import { Icon as Upstream, type IconProps as Props } from "@opencode-ai/ui/icon"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
const icons: Record<string, { path: string; viewBox: string }> = {
|
||||
"circle-x-outline": {
|
||||
viewBox: "0 0 20 20",
|
||||
path: `<path d="M7.5 7.5L12.5 12.5M12.5 7.5L7.5 12.5M18.3333 10C18.3333 14.6024 14.6024 18.3333 10 18.3333C5.39763 18.3333 1.66667 14.6024 1.66667 10C1.66667 5.39763 5.39763 1.66667 10 1.66667C14.6024 1.66667 18.3333 5.39763 18.3333 10Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
},
|
||||
"pull-request": {
|
||||
viewBox: "0 0 20 20",
|
||||
path: `<path d="M1.875 4.0625a2.8125 2.8125 0 1 1 3.75 2.6525v6.57a2.8138 2.8138 0 1 1-1.875 0V6.715A2.8125 2.8125 0 0 1 1.875 4.0625Zm7.096-.22125L11.96625.84625A.3125.3125 0 0 1 12.5 1.0675V3.125h1.25A3.125 3.125 0 0 1 16.875 6.25v7.035a2.8138 2.8138 0 1 1-1.875 0V6.25a1.25 1.25 0 0 0-1.25-1.25H12.5v2.0575a.3125.3125 0 0 1-.53375.22125L8.97125 4.34125a.3125.3125 0 0 1 0-.4425ZM4.6875 3.125a.9375.9375 0 1 0 0 1.875.9375.9375 0 0 0 0-1.875Zm0 11.875a.9375.9375 0 1 0 0 1.875.9375.9375 0 0 0 0-1.875Zm10.3125.9375a.9375.9375 0 1 0 1.875 0 .9375.9375 0 0 0-1.875 0Z" fill="currentColor"/>`,
|
||||
},
|
||||
refresh: {
|
||||
viewBox: "0 0 20 20",
|
||||
path: `<path d="M17.0837 10.0003C17.0837 13.9123 13.9123 17.0837 10.0003 17.0837C6.08833 17.0837 2.91699 13.9123 2.91699 10.0003C2.91699 6.08833 6.08833 2.91699 10.0003 2.91699C12.3717 2.91699 14.4722 4.07428 15.7698 5.83366M15.7698 5.83366V2.91699M15.7698 5.83366H12.8532" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
},
|
||||
memory: {
|
||||
viewBox: "0 0 24 24",
|
||||
path: `<path d="M2 7h4M2 12h4M2 17h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><rect width="16" height="20" x="4" y="2" rx="2" stroke="currentColor" stroke-width="2"/>`,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as path from "path"
|
||||
import { existsSync } from "fs"
|
||||
import * as vscode from "vscode"
|
||||
import { TRANSIENT as MEMORY_TRANSIENT } from "@kilocode/kilo-memory/schema"
|
||||
import type {
|
||||
KiloClient,
|
||||
Session,
|
||||
@@ -4307,8 +4308,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
for (const sid of trackedByDir) targets.add(sid)
|
||||
if (local && active) targets.add(active)
|
||||
if (targets.size === 0 && local) targets.add(undefined)
|
||||
const detail =
|
||||
props.detail && typeof props.detail === "object"
|
||||
const transient = event.type === "memory.error" && props.reason === MEMORY_TRANSIENT
|
||||
const detail = transient
|
||||
? undefined
|
||||
: props.detail && typeof props.detail === "object"
|
||||
? props.detail
|
||||
: event.type === "memory.error" && typeof props.reason === "string"
|
||||
? { type: "error", message: props.reason, reason: props.reason }
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { ExecFileOptionsWithStringEncoding } from "child_process"
|
||||
import type { Worktree } from "./WorktreeStateManager"
|
||||
import type { PRStatus, PRCheck, PRComment, CheckStatus, AggregateCheckStatus, PRState, ReviewDecision } from "./types"
|
||||
import type { PRStatus, PRCheck, PRComment, PRReviewer, AggregateCheckStatus } from "./types"
|
||||
import { execWithShellEnv } from "./shell-env"
|
||||
import { execGhRead } from "./gh"
|
||||
import { classifyPRError } from "./git-import"
|
||||
import type { Semaphore } from "./semaphore"
|
||||
import { parsePRResult, checkStatus, formatCheckDuration, parseComments, parseReviewers } from "./am-pr-utils"
|
||||
import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./am-pr-types"
|
||||
|
||||
interface PRStatusPollerOptions {
|
||||
getWorktrees: () => Worktree[]
|
||||
@@ -17,6 +19,7 @@ interface PRStatusPollerOptions {
|
||||
}
|
||||
|
||||
const GH_PROBE_TTL = 300_000 // 5 minutes — gh installation state rarely changes at runtime
|
||||
const GH_PROBE_FAILURE_TTL = 30_000 // 30 seconds — retry faster after a failed probe
|
||||
const MAX_BACKOFF = 120_000 // 2 minutes — cap for exponential backoff on repeated errors
|
||||
const BACKOFF_MULTIPLIER = 2
|
||||
const PR_LOOKUP_TTL = 10_000 // 10 seconds — short TTL; only the active worktree polls so this stays cheap
|
||||
@@ -171,7 +174,8 @@ export class PRStatusPoller {
|
||||
|
||||
private async probeGh(): Promise<boolean> {
|
||||
const now = Date.now()
|
||||
if (this.ghAvailable !== undefined && now - this.ghProbeTime < GH_PROBE_TTL) {
|
||||
const ttl = this.ghAvailable === false ? GH_PROBE_FAILURE_TTL : GH_PROBE_TTL
|
||||
if (this.ghAvailable !== undefined && now - this.ghProbeTime < ttl) {
|
||||
return this.ghAvailable
|
||||
}
|
||||
try {
|
||||
@@ -244,8 +248,9 @@ export class PRStatusPoller {
|
||||
return
|
||||
}
|
||||
|
||||
const [checks, comments] = await Promise.all([
|
||||
const [checks, reviewers, comments] = await Promise.all([
|
||||
this.fetchChecks(pr.number, wt.path),
|
||||
this.fetchReviewers(pr.number, wt.path),
|
||||
this.activeWorktreeId === worktreeId ? this.fetchComments(pr.number, wt.path) : undefined,
|
||||
])
|
||||
if (this.stale(generation)) return
|
||||
@@ -253,17 +258,22 @@ export class PRStatusPoller {
|
||||
const status: PRStatus = {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
body: pr.body,
|
||||
url: pr.url,
|
||||
state: pr.state,
|
||||
review: pr.review,
|
||||
checks,
|
||||
...(comments && { comments }),
|
||||
reviewers,
|
||||
...(comments && {
|
||||
comments: { total: comments.total, unresolved: comments.unresolved, comments: comments.comments },
|
||||
}),
|
||||
additions: pr.additions,
|
||||
deletions: pr.deletions,
|
||||
files: pr.files,
|
||||
}
|
||||
|
||||
const hash = `${worktreeId}:${pr.number}:${pr.state}:${pr.review}:${checks.status}:${checks.passed}/${checks.total}:${comments?.total ?? ""}:${comments?.unresolved ?? ""}`
|
||||
const reviewersSig = reviewers.map((r) => `${r.login}:${r.state}`).join(",")
|
||||
const hash = `${worktreeId}:${pr.number}:${pr.title}:${pr.state}:${pr.review}:${checks.status}:${checks.passed}/${checks.total}:${reviewersSig}:${pr.body ?? ""}:${comments?.total ?? ""}:${comments?.unresolved ?? ""}`
|
||||
if (this.lastHash.get(worktreeId) === hash) return
|
||||
this.lastHash.set(worktreeId, hash)
|
||||
|
||||
@@ -292,7 +302,7 @@ export class PRStatusPoller {
|
||||
}
|
||||
|
||||
private static readonly PR_JSON_FIELDS =
|
||||
"number,title,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,headRefOid"
|
||||
"number,title,body,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,headRefOid"
|
||||
|
||||
/** Return a cached PR lookup if still fresh, otherwise fetch and cache.
|
||||
* Keyed by branch name so multiple worktrees on the same branch share
|
||||
@@ -374,7 +384,7 @@ export class PRStatusPoller {
|
||||
passed: number
|
||||
failed: number
|
||||
pending: number
|
||||
items: PRCheck[]
|
||||
checks: PRCheck[]
|
||||
}> {
|
||||
try {
|
||||
const { stdout } = await this.gh(
|
||||
@@ -389,24 +399,24 @@ export class PRStatusPoller {
|
||||
completedAt?: string
|
||||
}>
|
||||
|
||||
const items: PRCheck[] = data.map((c) => ({
|
||||
const checks: PRCheck[] = data.map((c) => ({
|
||||
name: c.name,
|
||||
status: mapCheckStatus(c.state),
|
||||
status: checkStatus(c.state),
|
||||
url: c.link,
|
||||
duration: formatCheckDuration(c.startedAt, c.completedAt),
|
||||
}))
|
||||
|
||||
const total = items.length
|
||||
const passed = items.filter((c) => c.status === "success").length
|
||||
const failed = items.filter((c) => c.status === "failure").length
|
||||
const pending = items.filter((c) => c.status === "pending").length
|
||||
const total = checks.filter((c) => c.status !== "skipped").length
|
||||
const passed = checks.filter((c) => c.status === "success").length
|
||||
const failed = checks.filter((c) => c.status === "failure").length
|
||||
const pending = checks.filter((c) => c.status === "pending").length
|
||||
|
||||
const status: AggregateCheckStatus =
|
||||
total === 0 ? "none" : failed > 0 ? "failure" : pending > 0 ? "pending" : "success"
|
||||
|
||||
return { status, total, passed, failed, pending, items }
|
||||
return { status, total, passed, failed, pending, checks }
|
||||
} catch {
|
||||
return { status: "none", total: 0, passed: 0, failed: 0, pending: 0, items: [] }
|
||||
return { status: "none", total: 0, passed: 0, failed: 0, pending: 0, checks: [] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,13 +434,53 @@ export class PRStatusPoller {
|
||||
return info
|
||||
}
|
||||
|
||||
private async fetchReviewers(prNumber: number, cwd: string): Promise<PRReviewer[]> {
|
||||
try {
|
||||
const repo = await this.getRepoInfo(cwd)
|
||||
const query = `query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
reviewRequests(first: 20) {
|
||||
nodes { requestedReviewer { ... on User { login avatarUrl } } }
|
||||
}
|
||||
reviews(last: 20, states: [APPROVED, CHANGES_REQUESTED, COMMENTED]) {
|
||||
nodes { author { login avatarUrl } state }
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
const { stdout } = await this.gh(
|
||||
[
|
||||
"api",
|
||||
"graphql",
|
||||
"-f",
|
||||
`query=${query}`,
|
||||
"-F",
|
||||
`owner=${repo.owner}`,
|
||||
"-F",
|
||||
`repo=${repo.name}`,
|
||||
"-F",
|
||||
`number=${prNumber}`,
|
||||
],
|
||||
{ cwd, timeout: 15_000 },
|
||||
)
|
||||
const pr = JSON.parse(stdout)?.data?.repository?.pullRequest
|
||||
return parseReviewers(
|
||||
(pr?.reviewRequests?.nodes ?? []) as GhReviewRequest[],
|
||||
(pr?.reviews?.nodes ?? []) as GhReview[],
|
||||
)
|
||||
} catch (err) {
|
||||
this.options.log("Failed to fetch PR reviewers:", err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchComments(
|
||||
prNumber: number,
|
||||
cwd: string,
|
||||
): Promise<{ total: number; unresolved: number; items: PRComment[] }> {
|
||||
): Promise<{ total: number; unresolved: number; comments: PRComment[] }> {
|
||||
try {
|
||||
const repo = await this.getRepoInfo(cwd)
|
||||
|
||||
const query = `query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
@@ -469,107 +519,16 @@ export class PRStatusPoller {
|
||||
],
|
||||
{ cwd, timeout: 15_000 },
|
||||
)
|
||||
const result = JSON.parse(stdout)
|
||||
const threads = result?.data?.repository?.pullRequest?.reviewThreads?.nodes ?? []
|
||||
|
||||
const items: PRComment[] = []
|
||||
for (const thread of threads) {
|
||||
const first = thread.comments?.nodes?.[0]
|
||||
if (!first) continue
|
||||
items.push({
|
||||
id: first.id,
|
||||
author: first.author?.login ?? "unknown",
|
||||
avatar: first.author?.avatarUrl,
|
||||
body: first.body ?? "",
|
||||
file: first.path,
|
||||
line: first.line,
|
||||
url: first.url,
|
||||
resolved: thread.isResolved ?? false,
|
||||
createdAt: first.createdAt ? new Date(first.createdAt).getTime() : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const total = items.length
|
||||
const unresolved = items.filter((c) => !c.resolved).length
|
||||
return { total, unresolved, items }
|
||||
const pr = JSON.parse(stdout)?.data?.repository?.pullRequest
|
||||
const comments = parseComments((pr?.reviewThreads?.nodes ?? []) as GhThread[])
|
||||
return { total: comments.length, unresolved: comments.filter((c) => !c.resolved).length, comments }
|
||||
} catch (err) {
|
||||
this.options.log("Failed to fetch PR comments:", err)
|
||||
return { total: 0, unresolved: 0, items: [] }
|
||||
return { total: 0, unresolved: 0, comments: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface PRResult {
|
||||
number: number
|
||||
title: string
|
||||
url: string
|
||||
state: PRState
|
||||
review: ReviewDecision | null
|
||||
additions: number
|
||||
deletions: number
|
||||
files: number
|
||||
}
|
||||
|
||||
function parsePRResult(json: string): PRResult | null {
|
||||
const data = JSON.parse(json)
|
||||
if (!data.number) return null
|
||||
return {
|
||||
number: data.number,
|
||||
title: data.title ?? "",
|
||||
url: data.url ?? "",
|
||||
state: parsePRState(data.isDraft, data.state),
|
||||
review: parseReviewDecision(data.reviewDecision),
|
||||
additions: data.additions ?? 0,
|
||||
deletions: data.deletions ?? 0,
|
||||
files: data.changedFiles ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function parsePRState(isDraft: boolean, ghState: string): PRState {
|
||||
if (isDraft) return "draft"
|
||||
if (ghState === "MERGED") return "merged"
|
||||
if (ghState === "CLOSED") return "closed"
|
||||
return "open"
|
||||
}
|
||||
|
||||
function parseReviewDecision(decision: string | undefined): ReviewDecision | null {
|
||||
if (decision === "APPROVED") return "approved"
|
||||
if (decision === "CHANGES_REQUESTED") return "changes_requested"
|
||||
if (decision === "REVIEW_REQUIRED") return "pending"
|
||||
return null
|
||||
}
|
||||
|
||||
function mapCheckStatus(state: string): CheckStatus {
|
||||
switch (state.toUpperCase()) {
|
||||
case "SUCCESS":
|
||||
return "success"
|
||||
case "FAILURE":
|
||||
case "ERROR":
|
||||
return "failure"
|
||||
case "PENDING":
|
||||
case "QUEUED":
|
||||
case "IN_PROGRESS":
|
||||
case "REQUESTED":
|
||||
case "WAITING":
|
||||
return "pending"
|
||||
case "SKIPPED":
|
||||
return "skipped"
|
||||
case "CANCELLED":
|
||||
case "TIMED_OUT":
|
||||
case "STALE":
|
||||
case "STARTUP_FAILURE":
|
||||
return "cancelled"
|
||||
default:
|
||||
return "pending"
|
||||
}
|
||||
}
|
||||
|
||||
function formatCheckDuration(startedAt?: string, completedAt?: string): string | undefined {
|
||||
if (!startedAt || !completedAt) return undefined
|
||||
const secs = Math.round((new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 1000)
|
||||
return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`
|
||||
}
|
||||
|
||||
/** Run async thunks with bounded concurrency, returning settled results. */
|
||||
async function settled<T>(thunks: (() => Promise<T>)[], concurrency: number): Promise<PromiseSettledResult<T>[]> {
|
||||
const results: PromiseSettledResult<T>[] = new Array(thunks.length)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { PRState, ReviewDecision } from "./types"
|
||||
|
||||
// Raw shapes returned by `gh pr view --json`
|
||||
|
||||
export interface GhAuthor {
|
||||
login?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
export interface GhComment {
|
||||
id: string
|
||||
author?: GhAuthor
|
||||
body?: string
|
||||
path?: string
|
||||
line?: number
|
||||
url?: string
|
||||
createdAt?: string
|
||||
}
|
||||
export interface GhThread {
|
||||
isResolved?: boolean
|
||||
comments?: { nodes?: GhComment[] }
|
||||
}
|
||||
export interface GhReviewRequest {
|
||||
requestedReviewer?: GhAuthor
|
||||
}
|
||||
export interface GhReview {
|
||||
author?: GhAuthor
|
||||
state?: string
|
||||
}
|
||||
|
||||
export interface PRResult {
|
||||
number: number
|
||||
title: string
|
||||
body: string
|
||||
url: string
|
||||
state: PRState
|
||||
review: ReviewDecision | null
|
||||
additions: number
|
||||
deletions: number
|
||||
files: number
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { CheckStatus, PRComment, PRReviewer, ReviewerState } from "./types"
|
||||
import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./am-pr-types"
|
||||
|
||||
export function parsePRResult(json: string): PRResult | null {
|
||||
const data = JSON.parse(json)
|
||||
if (!data.number) return null
|
||||
const state = data.isDraft ? "draft" : (data.state?.toLowerCase() ?? "open")
|
||||
const decision = data.reviewDecision as string | undefined
|
||||
const review =
|
||||
decision === "APPROVED"
|
||||
? "approved"
|
||||
: decision === "CHANGES_REQUESTED"
|
||||
? "changes_requested"
|
||||
: decision === "REVIEW_REQUIRED"
|
||||
? "pending"
|
||||
: null
|
||||
return {
|
||||
number: data.number,
|
||||
title: data.title ?? "",
|
||||
body: data.body ?? "",
|
||||
url: data.url ?? "",
|
||||
state,
|
||||
review,
|
||||
additions: data.additions ?? 0,
|
||||
deletions: data.deletions ?? 0,
|
||||
files: data.changedFiles ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function checkStatus(state: string): CheckStatus {
|
||||
switch (state.toUpperCase()) {
|
||||
case "SUCCESS":
|
||||
case "NEUTRAL":
|
||||
return "success"
|
||||
case "FAILURE":
|
||||
case "ERROR":
|
||||
case "ACTION_REQUIRED":
|
||||
return "failure"
|
||||
case "PENDING":
|
||||
case "QUEUED":
|
||||
case "IN_PROGRESS":
|
||||
case "REQUESTED":
|
||||
case "WAITING":
|
||||
return "pending"
|
||||
case "SKIPPED":
|
||||
return "skipped"
|
||||
case "CANCELLED":
|
||||
case "TIMED_OUT":
|
||||
case "STALE":
|
||||
case "STARTUP_FAILURE":
|
||||
return "cancelled"
|
||||
default:
|
||||
return "pending"
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCheckDuration(startedAt?: string, completedAt?: string): string | undefined {
|
||||
if (!startedAt || !completedAt) return undefined
|
||||
const secs = Math.round((new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 1000)
|
||||
return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`
|
||||
}
|
||||
|
||||
const REVIEWER_STATE: Record<string, ReviewerState> = {
|
||||
APPROVED: "approved",
|
||||
CHANGES_REQUESTED: "changes_requested",
|
||||
COMMENTED: "commented",
|
||||
}
|
||||
|
||||
export function parseComments(threads: GhThread[]): PRComment[] {
|
||||
const items: PRComment[] = []
|
||||
for (const thread of threads) {
|
||||
const first = thread.comments?.nodes?.[0]
|
||||
if (!first) continue
|
||||
items.push({
|
||||
id: first.id,
|
||||
author: first.author?.login ?? "unknown",
|
||||
avatar: first.author?.avatarUrl,
|
||||
body: first.body ?? "",
|
||||
file: first.path,
|
||||
line: first.line,
|
||||
url: first.url,
|
||||
resolved: thread.isResolved ?? false,
|
||||
createdAt: first.createdAt ? new Date(first.createdAt).getTime() : undefined,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export function parseReviewers(requests: GhReviewRequest[], reviews: GhReview[]): PRReviewer[] {
|
||||
const map = new Map<string, PRReviewer>()
|
||||
for (const node of requests) {
|
||||
const user = node.requestedReviewer
|
||||
if (!user?.login) continue
|
||||
map.set(user.login, { login: user.login, avatar: user.avatarUrl, state: "pending" })
|
||||
}
|
||||
for (const node of reviews) {
|
||||
const login = node.author?.login
|
||||
if (!login) continue
|
||||
const state = REVIEWER_STATE[node.state ?? ""] ?? "pending"
|
||||
if (!map.has(login) || state !== "commented") {
|
||||
map.set(login, { login, avatar: node.author?.avatarUrl, state })
|
||||
}
|
||||
}
|
||||
return [...map.values()]
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export class PRStatusBridge {
|
||||
readonly poller: PRStatusPoller
|
||||
private readonly cache = new Map<string, AgentManagerOutMessage>()
|
||||
private readonly host: PRBridgeHost
|
||||
private lastErrorNotified: "gh_missing" | "gh_auth" | "fetch_failed" | undefined
|
||||
|
||||
constructor(host: PRBridgeHost) {
|
||||
this.host = host
|
||||
@@ -61,6 +62,8 @@ export class PRStatusBridge {
|
||||
/** Replay cached PR statuses to a freshly-connected webview. */
|
||||
replay(): void {
|
||||
this.cache.forEach((msg) => this.host.postToWebview(msg))
|
||||
if (this.lastErrorNotified === "gh_auth" || this.lastErrorNotified === "gh_missing")
|
||||
this.host.postToWebview({ type: "agentManager.prError", error: this.lastErrorNotified } as AgentManagerOutMessage)
|
||||
}
|
||||
|
||||
snapshot(): Map<string, PRStatus> {
|
||||
@@ -78,8 +81,8 @@ export class PRStatusBridge {
|
||||
return true
|
||||
}
|
||||
if (m.type === "agentManager.openPR") {
|
||||
const wt = this.host.getWorktrees().find((w: Worktree) => w.id === m.worktreeId)
|
||||
if (wt?.prUrl) this.host.openExternal(wt.prUrl)
|
||||
const url = (m.url as string) ?? this.host.getWorktrees().find((w: Worktree) => w.id === m.worktreeId)?.prUrl
|
||||
if (url) this.host.openExternal(url)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -93,6 +96,13 @@ export class PRStatusBridge {
|
||||
reset(): void {
|
||||
this.poller.stop()
|
||||
this.cache.clear()
|
||||
this.lastErrorNotified = undefined
|
||||
}
|
||||
|
||||
notifyError(err: "gh_missing" | "gh_auth" | "fetch_failed"): void {
|
||||
if (this.lastErrorNotified === err) return
|
||||
this.lastErrorNotified = err
|
||||
this.host.postToWebview({ type: "agentManager.prError", error: err } as AgentManagerOutMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,10 +125,15 @@ function bridgePollerOpts(bridge: PRStatusBridge, host: PRBridgeHost) {
|
||||
pr: null,
|
||||
error: err,
|
||||
} as AgentManagerOutMessage)
|
||||
// Always forward auth/missing errors so the webview can show a toast,
|
||||
// regardless of whether prior data exists. Deduplicate per error type
|
||||
// so multiple failing worktrees don't produce multiple toasts.
|
||||
if (err === "gh_auth" || err === "gh_missing") bridge.notifyError(err)
|
||||
return
|
||||
}
|
||||
const msg = { type: "agentManager.prStatus", worktreeId: id, pr, error: err } as AgentManagerOutMessage
|
||||
bridge["cache"].set(id, msg)
|
||||
bridge["lastErrorNotified"] = undefined
|
||||
host.postToWebview(msg)
|
||||
host.updateWorktreePR(id, pr?.number, pr?.url, pr?.state)
|
||||
},
|
||||
|
||||
@@ -71,9 +71,18 @@ export interface PRComment {
|
||||
createdAt?: number
|
||||
}
|
||||
|
||||
export type ReviewerState = "approved" | "changes_requested" | "pending" | "commented"
|
||||
|
||||
export interface PRReviewer {
|
||||
login: string
|
||||
avatar?: string
|
||||
state: ReviewerState
|
||||
}
|
||||
|
||||
export interface PRStatus {
|
||||
number: number
|
||||
title: string
|
||||
body?: string
|
||||
url: string
|
||||
state: PRState
|
||||
review: ReviewDecision | null
|
||||
@@ -83,12 +92,13 @@ export interface PRStatus {
|
||||
passed: number
|
||||
failed: number
|
||||
pending: number
|
||||
items: PRCheck[]
|
||||
checks: PRCheck[]
|
||||
}
|
||||
reviewers: PRReviewer[]
|
||||
comments?: {
|
||||
total: number
|
||||
unresolved: number
|
||||
items: PRComment[]
|
||||
comments: PRComment[]
|
||||
}
|
||||
additions: number
|
||||
deletions: number
|
||||
@@ -379,6 +389,11 @@ interface PRStatusOutMessage {
|
||||
error?: "gh_missing" | "gh_auth" | "fetch_failed"
|
||||
}
|
||||
|
||||
interface PRErrorOutMessage {
|
||||
type: "agentManager.prError"
|
||||
error: "gh_missing" | "gh_auth" | "fetch_failed"
|
||||
}
|
||||
|
||||
interface ActionOutMessage {
|
||||
type: "action"
|
||||
action: string
|
||||
@@ -418,6 +433,7 @@ export type AgentManagerOutMessage =
|
||||
| RevertWorktreeFileResultMessage
|
||||
| DiffBranchesMessage
|
||||
| PRStatusOutMessage
|
||||
| PRErrorOutMessage
|
||||
| ActionOutMessage
|
||||
| RunStatusMessage
|
||||
| TerminalCreatedMessage
|
||||
@@ -739,6 +755,7 @@ interface OpenPRIn {
|
||||
type: "agentManager.openPR"
|
||||
projectId?: string
|
||||
worktreeId: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface OpenSessionsIn {
|
||||
|
||||
@@ -450,7 +450,7 @@ export type WebviewMessage =
|
||||
| { type: "sessionUpdated"; session: ReturnType<typeof sessionToWebview> }
|
||||
| { type: "sessionDeleted"; sessionID: string }
|
||||
| { type: "messageRemoved"; sessionID: string; messageID: string }
|
||||
| { type: "sessionError"; sessionID?: string; error?: unknown }
|
||||
| { type: "sessionError"; eventID: string; sessionID?: string; error?: unknown }
|
||||
| {
|
||||
type: "sandboxStatus"
|
||||
sessionID: string
|
||||
@@ -620,6 +620,7 @@ export function mapSSEEventToWebviewMessage(event: StreamEvent, sessionID: strin
|
||||
case "session.error": {
|
||||
return {
|
||||
type: "sessionError",
|
||||
eventID: event.id,
|
||||
sessionID: event.properties.sessionID,
|
||||
error: event.properties.error,
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ const TSX_FILES = [
|
||||
path.join(ROOT, "webview-ui/agent-manager/MultiModelSelector.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/ApplyDialog.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/WorktreeItem.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/pr/PRBadge.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/SectionHeader.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/SidebarSectionHeader.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/SidebarSearchMenu.tsx"),
|
||||
|
||||
@@ -86,8 +86,8 @@ describe("Agent Manager orchestration domain", () => {
|
||||
url: "https://example.com/pr/42",
|
||||
state: "open",
|
||||
review: "approved",
|
||||
checks: { status: "success", total: 1, passed: 1, failed: 0, pending: 0, items: [] },
|
||||
comments: { total: 2, unresolved: 1, items: [] },
|
||||
checks: { status: "success", total: 1, passed: 1, failed: 0, pending: 0, checks: [] },
|
||||
comments: { total: 2, unresolved: 1, comments: [], reviewers: [] },
|
||||
additions: 10,
|
||||
deletions: 2,
|
||||
files: 1,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { PRStatusBridge } from "../../src/agent-manager/pr-status-bridge"
|
||||
import type { AgentManagerOutMessage, PRStatus } from "../../src/agent-manager/types"
|
||||
|
||||
const pr: PRStatus = {
|
||||
number: 1,
|
||||
title: "my PR",
|
||||
url: "https://github.com/x/y/pull/1",
|
||||
state: "open",
|
||||
review: null,
|
||||
checks: { status: "none", total: 0, passed: 0, failed: 0, pending: 0, checks: [] },
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
files: 0,
|
||||
}
|
||||
|
||||
function harness(opts: { hasPersisted?: boolean } = {}) {
|
||||
const sent: AgentManagerOutMessage[] = []
|
||||
const worktrees: { id: string; prUrl?: string }[] = []
|
||||
const bridge = PRStatusBridge.create({
|
||||
getWorktrees: () => worktrees as never,
|
||||
getWorkspaceRoot: () => "/repo",
|
||||
postToWebview: (msg) => sent.push(msg),
|
||||
updateWorktreePR: () => {},
|
||||
hasPersistedPR: () => opts.hasPersisted ?? false,
|
||||
openExternal: () => {},
|
||||
log: () => {},
|
||||
})
|
||||
const onStatus = (bridge.poller as unknown as { options: { onStatus: (...a: unknown[]) => void } }).options.onStatus
|
||||
return { bridge, sent, onStatus }
|
||||
}
|
||||
|
||||
// --- error deduplication ---
|
||||
|
||||
describe("PRStatusBridge.notifyError", () => {
|
||||
it("sends the first error notification", () => {
|
||||
const { bridge, sent } = harness()
|
||||
bridge.notifyError("gh_missing")
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]).toEqual(expect.objectContaining({ type: "agentManager.prError", error: "gh_missing" }))
|
||||
})
|
||||
|
||||
it("deduplicates the same error type", () => {
|
||||
const { bridge, sent } = harness()
|
||||
bridge.notifyError("gh_auth")
|
||||
bridge.notifyError("gh_auth")
|
||||
expect(sent).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("sends again when error type changes", () => {
|
||||
const { bridge, sent } = harness()
|
||||
bridge.notifyError("gh_missing")
|
||||
bridge.notifyError("gh_auth")
|
||||
expect(sent).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
// --- onStatus cache suppression ---
|
||||
|
||||
describe("PRStatusBridge onStatus", () => {
|
||||
it("forwards a successful status to the webview", () => {
|
||||
const { sent, onStatus } = harness()
|
||||
onStatus("wt1", pr)
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]).toEqual(expect.objectContaining({ type: "agentManager.prStatus", worktreeId: "wt1", pr }))
|
||||
})
|
||||
|
||||
it("forwards pr:null error when no cache entry and no persisted PR", () => {
|
||||
const { sent, onStatus } = harness()
|
||||
onStatus("wt1", null, "fetch_failed")
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]).toEqual(expect.objectContaining({ type: "agentManager.prStatus", worktreeId: "wt1", pr: null }))
|
||||
})
|
||||
|
||||
it("suppresses pr:null error when cache entry exists", () => {
|
||||
const { sent, onStatus } = harness()
|
||||
onStatus("wt1", pr)
|
||||
sent.length = 0
|
||||
onStatus("wt1", null, "fetch_failed")
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("suppresses pr:null error when persisted PR exists", () => {
|
||||
const { sent, onStatus } = harness({ hasPersisted: true })
|
||||
onStatus("wt1", null, "fetch_failed")
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("forwards gh_auth error even when cache entry exists", () => {
|
||||
const { sent, onStatus } = harness()
|
||||
onStatus("wt1", pr)
|
||||
sent.length = 0
|
||||
onStatus("wt1", null, "gh_auth")
|
||||
const errorMsg = sent.find((m) => m.type === "agentManager.prError")
|
||||
expect(errorMsg).toEqual(expect.objectContaining({ error: "gh_auth" }))
|
||||
})
|
||||
|
||||
it("forwards gh_missing error even when cache entry exists", () => {
|
||||
const { sent, onStatus } = harness()
|
||||
onStatus("wt1", pr)
|
||||
sent.length = 0
|
||||
onStatus("wt1", null, "gh_missing")
|
||||
const errorMsg = sent.find((m) => m.type === "agentManager.prError")
|
||||
expect(errorMsg).toEqual(expect.objectContaining({ error: "gh_missing" }))
|
||||
})
|
||||
})
|
||||
|
||||
// --- replay ---
|
||||
|
||||
describe("PRStatusBridge.replay", () => {
|
||||
it("replays cached status messages", () => {
|
||||
const { bridge, sent, onStatus } = harness()
|
||||
onStatus("wt1", pr)
|
||||
sent.length = 0
|
||||
bridge.replay()
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]).toEqual(expect.objectContaining({ type: "agentManager.prStatus", worktreeId: "wt1" }))
|
||||
})
|
||||
|
||||
it("replays the last auth error on reconnect", () => {
|
||||
const { bridge, sent, onStatus } = harness()
|
||||
onStatus("wt1", null, "gh_auth")
|
||||
sent.length = 0
|
||||
bridge.replay()
|
||||
expect(
|
||||
sent.some((m) => m.type === "agentManager.prError" && (m as never as { error: string }).error === "gh_auth"),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("does not replay fetch_failed errors", () => {
|
||||
const { bridge, sent, onStatus } = harness()
|
||||
onStatus("wt1", null, "fetch_failed")
|
||||
sent.length = 0
|
||||
bridge.replay()
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// --- snapshot ---
|
||||
|
||||
describe("PRStatusBridge.snapshot", () => {
|
||||
it("returns only entries with a non-null pr", () => {
|
||||
const { bridge, onStatus } = harness()
|
||||
onStatus("wt1", pr)
|
||||
onStatus("wt2", pr)
|
||||
expect(bridge.snapshot().size).toBe(2)
|
||||
})
|
||||
|
||||
it("excludes entries where pr was null", () => {
|
||||
const { bridge, onStatus } = harness({ hasPersisted: true })
|
||||
onStatus("wt1", null, "fetch_failed")
|
||||
expect(bridge.snapshot().size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// --- remove / reset ---
|
||||
|
||||
describe("PRStatusBridge.remove", () => {
|
||||
it("removes a cached entry so it is no longer replayed", () => {
|
||||
const { bridge, sent, onStatus } = harness()
|
||||
onStatus("wt1", pr)
|
||||
bridge.remove("wt1")
|
||||
sent.length = 0
|
||||
bridge.replay()
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("PRStatusBridge.reset", () => {
|
||||
it("clears cache and error state so replay sends nothing", () => {
|
||||
const { bridge, sent, onStatus } = harness()
|
||||
onStatus("wt1", pr)
|
||||
bridge.notifyError("gh_auth")
|
||||
bridge.reset()
|
||||
sent.length = 0
|
||||
bridge.replay()
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("allows the same error to be sent again after reset", () => {
|
||||
const { bridge, sent } = harness()
|
||||
bridge.notifyError("gh_auth")
|
||||
bridge.reset()
|
||||
sent.length = 0
|
||||
bridge.notifyError("gh_auth")
|
||||
expect(sent).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,324 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
parsePRResult,
|
||||
checkStatus,
|
||||
formatCheckDuration,
|
||||
parseComments,
|
||||
parseReviewers,
|
||||
} from "../../src/agent-manager/am-pr-utils"
|
||||
import type { GhThread, GhReviewRequest, GhReview } from "../../src/agent-manager/am-pr-types"
|
||||
|
||||
// --- parsePRResult ---
|
||||
|
||||
describe("parsePRResult", () => {
|
||||
it("returns null when number is missing", () => {
|
||||
expect(parsePRResult(JSON.stringify({ title: "foo" }))).toBeNull()
|
||||
})
|
||||
|
||||
it("parses an open PR", () => {
|
||||
const raw = {
|
||||
number: 42,
|
||||
title: "my PR",
|
||||
body: "desc",
|
||||
url: "https://github.com/x/y/pull/42",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
reviewDecision: null,
|
||||
additions: 10,
|
||||
deletions: 3,
|
||||
changedFiles: 2,
|
||||
}
|
||||
expect(parsePRResult(JSON.stringify(raw))).toEqual({
|
||||
number: 42,
|
||||
title: "my PR",
|
||||
body: "desc",
|
||||
url: "https://github.com/x/y/pull/42",
|
||||
state: "open",
|
||||
review: null,
|
||||
additions: 10,
|
||||
deletions: 3,
|
||||
files: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it("maps isDraft to draft state regardless of gh state field", () => {
|
||||
const raw = {
|
||||
number: 1,
|
||||
title: "",
|
||||
body: "",
|
||||
url: "",
|
||||
state: "OPEN",
|
||||
isDraft: true,
|
||||
reviewDecision: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
changedFiles: 0,
|
||||
}
|
||||
expect(parsePRResult(JSON.stringify(raw))?.state).toBe("draft")
|
||||
})
|
||||
|
||||
it("maps MERGED state", () => {
|
||||
const raw = {
|
||||
number: 1,
|
||||
title: "",
|
||||
body: "",
|
||||
url: "",
|
||||
state: "MERGED",
|
||||
isDraft: false,
|
||||
reviewDecision: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
changedFiles: 0,
|
||||
}
|
||||
expect(parsePRResult(JSON.stringify(raw))?.state).toBe("merged")
|
||||
})
|
||||
|
||||
it("maps CLOSED state", () => {
|
||||
const raw = {
|
||||
number: 1,
|
||||
title: "",
|
||||
body: "",
|
||||
url: "",
|
||||
state: "CLOSED",
|
||||
isDraft: false,
|
||||
reviewDecision: null,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
changedFiles: 0,
|
||||
}
|
||||
expect(parsePRResult(JSON.stringify(raw))?.state).toBe("closed")
|
||||
})
|
||||
|
||||
it("maps APPROVED review decision", () => {
|
||||
const raw = {
|
||||
number: 1,
|
||||
title: "",
|
||||
body: "",
|
||||
url: "",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
reviewDecision: "APPROVED",
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
changedFiles: 0,
|
||||
}
|
||||
expect(parsePRResult(JSON.stringify(raw))?.review).toBe("approved")
|
||||
})
|
||||
|
||||
it("maps CHANGES_REQUESTED review decision", () => {
|
||||
const raw = {
|
||||
number: 1,
|
||||
title: "",
|
||||
body: "",
|
||||
url: "",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
reviewDecision: "CHANGES_REQUESTED",
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
changedFiles: 0,
|
||||
}
|
||||
expect(parsePRResult(JSON.stringify(raw))?.review).toBe("changes_requested")
|
||||
})
|
||||
|
||||
it("maps REVIEW_REQUIRED review decision to pending", () => {
|
||||
const raw = {
|
||||
number: 1,
|
||||
title: "",
|
||||
body: "",
|
||||
url: "",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
reviewDecision: "REVIEW_REQUIRED",
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
changedFiles: 0,
|
||||
}
|
||||
expect(parsePRResult(JSON.stringify(raw))?.review).toBe("pending")
|
||||
})
|
||||
|
||||
it("returns null review for unknown decision", () => {
|
||||
const raw = {
|
||||
number: 1,
|
||||
title: "",
|
||||
body: "",
|
||||
url: "",
|
||||
state: "OPEN",
|
||||
isDraft: false,
|
||||
reviewDecision: "SOMETHING_ELSE",
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
changedFiles: 0,
|
||||
}
|
||||
expect(parsePRResult(JSON.stringify(raw))?.review).toBeNull()
|
||||
})
|
||||
|
||||
it("defaults missing fields to empty strings and zeros", () => {
|
||||
const result = parsePRResult(JSON.stringify({ number: 5 }))
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ title: "", body: "", url: "", additions: 0, deletions: 0, files: 0 }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --- checkStatus ---
|
||||
|
||||
describe("checkStatus", () => {
|
||||
it("maps SUCCESS", () => expect(checkStatus("SUCCESS")).toBe("success"))
|
||||
it("maps FAILURE", () => expect(checkStatus("FAILURE")).toBe("failure"))
|
||||
it("maps ERROR to failure", () => expect(checkStatus("ERROR")).toBe("failure"))
|
||||
it("maps PENDING", () => expect(checkStatus("PENDING")).toBe("pending"))
|
||||
it("maps QUEUED to pending", () => expect(checkStatus("QUEUED")).toBe("pending"))
|
||||
it("maps IN_PROGRESS to pending", () => expect(checkStatus("IN_PROGRESS")).toBe("pending"))
|
||||
it("maps REQUESTED to pending", () => expect(checkStatus("REQUESTED")).toBe("pending"))
|
||||
it("maps WAITING to pending", () => expect(checkStatus("WAITING")).toBe("pending"))
|
||||
it("maps SKIPPED", () => expect(checkStatus("SKIPPED")).toBe("skipped"))
|
||||
it("maps CANCELLED", () => expect(checkStatus("CANCELLED")).toBe("cancelled"))
|
||||
it("maps TIMED_OUT to cancelled", () => expect(checkStatus("TIMED_OUT")).toBe("cancelled"))
|
||||
it("maps STALE to cancelled", () => expect(checkStatus("STALE")).toBe("cancelled"))
|
||||
it("maps STARTUP_FAILURE to cancelled", () => expect(checkStatus("STARTUP_FAILURE")).toBe("cancelled"))
|
||||
it("maps unknown state to pending", () => expect(checkStatus("WHATEVER")).toBe("pending"))
|
||||
it("is case-insensitive", () => expect(checkStatus("success")).toBe("success"))
|
||||
})
|
||||
|
||||
// --- formatCheckDuration ---
|
||||
|
||||
describe("formatCheckDuration", () => {
|
||||
it("returns undefined when startedAt is missing", () => {
|
||||
expect(formatCheckDuration(undefined, "2024-01-01T00:01:00Z")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns undefined when completedAt is missing", () => {
|
||||
expect(formatCheckDuration("2024-01-01T00:00:00Z", undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("formats sub-minute durations in seconds", () => {
|
||||
expect(formatCheckDuration("2024-01-01T00:00:00Z", "2024-01-01T00:00:45Z")).toBe("45s")
|
||||
})
|
||||
|
||||
it("formats durations over a minute as m/s", () => {
|
||||
expect(formatCheckDuration("2024-01-01T00:00:00Z", "2024-01-01T00:02:30Z")).toBe("2m 30s")
|
||||
})
|
||||
|
||||
it("formats exactly 60 seconds as 1m 0s", () => {
|
||||
expect(formatCheckDuration("2024-01-01T00:00:00Z", "2024-01-01T00:01:00Z")).toBe("1m 0s")
|
||||
})
|
||||
})
|
||||
|
||||
// --- parseComments ---
|
||||
|
||||
describe("parseComments", () => {
|
||||
it("returns empty array for empty threads", () => {
|
||||
expect(parseComments([])).toEqual([])
|
||||
})
|
||||
|
||||
it("skips threads with no comments", () => {
|
||||
const threads: GhThread[] = [{ isResolved: false, comments: { nodes: [] } }]
|
||||
expect(parseComments(threads)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("parses a resolved thread", () => {
|
||||
const threads: GhThread[] = [
|
||||
{
|
||||
isResolved: true,
|
||||
comments: {
|
||||
nodes: [
|
||||
{
|
||||
id: "c1",
|
||||
author: { login: "alice", avatarUrl: "https://avatar" },
|
||||
body: "looks good",
|
||||
path: "src/foo.ts",
|
||||
line: 10,
|
||||
url: "https://url",
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
expect(parseComments(threads)).toEqual([
|
||||
{
|
||||
id: "c1",
|
||||
author: "alice",
|
||||
avatar: "https://avatar",
|
||||
body: "looks good",
|
||||
file: "src/foo.ts",
|
||||
line: 10,
|
||||
url: "https://url",
|
||||
resolved: true,
|
||||
createdAt: new Date("2024-01-01T00:00:00Z").getTime(),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("defaults missing author to 'unknown'", () => {
|
||||
const threads: GhThread[] = [{ isResolved: false, comments: { nodes: [{ id: "c2", body: "note" }] } }]
|
||||
expect(parseComments(threads)[0]?.author).toBe("unknown")
|
||||
})
|
||||
|
||||
it("only uses the first comment of each thread", () => {
|
||||
const threads: GhThread[] = [
|
||||
{
|
||||
isResolved: false,
|
||||
comments: {
|
||||
nodes: [
|
||||
{ id: "first", body: "first comment" },
|
||||
{ id: "second", body: "second comment" },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
const result = parseComments(threads)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.id).toBe("first")
|
||||
})
|
||||
})
|
||||
|
||||
// --- parseReviewers ---
|
||||
|
||||
describe("parseReviewers", () => {
|
||||
it("returns empty array with no requests or reviews", () => {
|
||||
expect(parseReviewers([], [])).toEqual([])
|
||||
})
|
||||
|
||||
it("adds pending reviewer from request", () => {
|
||||
const requests: GhReviewRequest[] = [{ requestedReviewer: { login: "alice", avatarUrl: "https://avatar" } }]
|
||||
expect(parseReviewers(requests, [])).toEqual([{ login: "alice", avatar: "https://avatar", state: "pending" }])
|
||||
})
|
||||
|
||||
it("skips review requests without a login", () => {
|
||||
const requests: GhReviewRequest[] = [{ requestedReviewer: {} }]
|
||||
expect(parseReviewers(requests, [])).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("adds reviewer from review when not in requests", () => {
|
||||
const reviews: GhReview[] = [{ author: { login: "bob" }, state: "APPROVED" }]
|
||||
expect(parseReviewers([], reviews)).toEqual([{ login: "bob", avatar: undefined, state: "approved" }])
|
||||
})
|
||||
|
||||
it("upgrades pending request to approved when review arrives", () => {
|
||||
const requests: GhReviewRequest[] = [{ requestedReviewer: { login: "alice" } }]
|
||||
const reviews: GhReview[] = [{ author: { login: "alice" }, state: "APPROVED" }]
|
||||
expect(parseReviewers(requests, reviews)).toEqual([{ login: "alice", avatar: undefined, state: "approved" }])
|
||||
})
|
||||
|
||||
it("does not downgrade approved to commented", () => {
|
||||
const requests: GhReviewRequest[] = [{ requestedReviewer: { login: "alice" } }]
|
||||
const reviews: GhReview[] = [
|
||||
{ author: { login: "alice" }, state: "APPROVED" },
|
||||
{ author: { login: "alice" }, state: "COMMENTED" },
|
||||
]
|
||||
expect(parseReviewers(requests, reviews)[0]?.state).toBe("approved")
|
||||
})
|
||||
|
||||
it("does upgrade pending to changes_requested", () => {
|
||||
const requests: GhReviewRequest[] = [{ requestedReviewer: { login: "alice" } }]
|
||||
const reviews: GhReview[] = [{ author: { login: "alice" }, state: "CHANGES_REQUESTED" }]
|
||||
expect(parseReviewers(requests, reviews)[0]?.state).toBe("changes_requested")
|
||||
})
|
||||
|
||||
it("skips reviews without a login", () => {
|
||||
const reviews: GhReview[] = [{ author: {}, state: "APPROVED" }]
|
||||
expect(parseReviewers([], reviews)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -292,4 +292,20 @@ describe("validateCustomProvider – variant name validation", () => {
|
||||
const saved = out.result!.config.models["model-1"] as Record<string, unknown>
|
||||
expect(saved.modalities).toEqual({ input: ["text", "audio", "video", "pdf"], output: ["text", "audio"] })
|
||||
})
|
||||
|
||||
it("handles multiple models with reasoning and images toggled", () => {
|
||||
const form = base()
|
||||
form.models = [
|
||||
{ id: "m1", name: "Model 1", reasoning: true, supportsImages: true, modalities: {}, variants: [] },
|
||||
{ id: "m2", name: "Model 2", reasoning: true, supportsImages: false, modalities: {}, variants: [] },
|
||||
]
|
||||
const out = validateCustomProvider(args(form))
|
||||
expect(out.result).toBeDefined()
|
||||
const m1 = out.result!.config.models["m1"] as Record<string, unknown>
|
||||
const m2 = out.result!.config.models["m2"] as Record<string, unknown>
|
||||
expect(m1.reasoning).toBe(true)
|
||||
expect(m1.modalities).toEqual({ input: ["text", "image"] })
|
||||
expect(m2.reasoning).toBe(true)
|
||||
expect(m2.modalities).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -127,6 +127,43 @@ describe("KiloProvider memory events", () => {
|
||||
expect(calls).toEqual(["/repo", "/repo"])
|
||||
})
|
||||
|
||||
it("refreshes status without forwarding transient memory errors", async () => {
|
||||
const calls: string[] = []
|
||||
const posts: unknown[] = []
|
||||
const client = {
|
||||
memory: {
|
||||
status: async (input: { directory: string }) => {
|
||||
calls.push(input.directory)
|
||||
return { data: status(input.directory) }
|
||||
},
|
||||
},
|
||||
} as unknown as KiloClient
|
||||
const provider = new KiloProvider(
|
||||
{} as never,
|
||||
{
|
||||
getClient: () => client,
|
||||
} as never,
|
||||
)
|
||||
const item = provider as unknown as Internals
|
||||
item.webview = { postMessage: async (message) => posts.push(message) }
|
||||
item.currentSession = { id: "ses_active" }
|
||||
item.trackedSessionIds.add("ses_active")
|
||||
provider.setSessionDirectory("ses_active", "/repo")
|
||||
|
||||
item.handleEvent(
|
||||
{
|
||||
type: "memory.error",
|
||||
properties: { sessionID: "ses_active", reason: "transient" },
|
||||
},
|
||||
"/repo",
|
||||
)
|
||||
await item.memory.idle()
|
||||
|
||||
expect(posts).not.toContainEqual(expect.objectContaining({ type: "memoryEvent" }))
|
||||
expect(posts).toContainEqual(expect.objectContaining({ type: "memoryLoaded", sessionID: "ses_active" }))
|
||||
expect(calls).toEqual(["/repo"])
|
||||
})
|
||||
|
||||
it("uses the project directory when toggling memory", async () => {
|
||||
const calls: unknown[] = []
|
||||
const client = {
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
Event,
|
||||
EventSessionStatus,
|
||||
EventSessionTurnClose,
|
||||
EventSessionError,
|
||||
EventSandboxStatusChanged,
|
||||
EventPermissionAsked,
|
||||
EventPermissionReplied,
|
||||
@@ -375,6 +376,30 @@ describe("mapSSEEventToWebviewMessage", () => {
|
||||
expect(msg).toEqual({ type: "sessionTurnClosed", sessionID: "sess-1", reason: "interrupted" })
|
||||
})
|
||||
|
||||
it("maps session errors with their event identity and message", () => {
|
||||
const event: EventSessionError = {
|
||||
id: "evt-error",
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "sess-1",
|
||||
error: {
|
||||
name: "APIError",
|
||||
data: {
|
||||
message: "prompt_cache_breakpoint is not supported on this model",
|
||||
isRetryable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(mapSSEEventToWebviewMessage(event, "sess-1")).toEqual({
|
||||
type: "sessionError",
|
||||
eventID: "evt-error",
|
||||
sessionID: "sess-1",
|
||||
error: event.properties.error,
|
||||
})
|
||||
})
|
||||
|
||||
it("maps permission.asked to permissionRequest", () => {
|
||||
const event: EventPermissionAsked = {
|
||||
type: "permission.asked",
|
||||
|
||||
@@ -18,6 +18,11 @@ describe("fmtPrice", () => {
|
||||
expect(fmtPrice(0)).toBe("Free")
|
||||
})
|
||||
|
||||
it("returns dash for negative price", () => {
|
||||
expect(fmtPrice(-1)).toBe("—")
|
||||
expect(fmtPrice(-1000000)).toBe("—")
|
||||
})
|
||||
|
||||
it("uses 4 decimal places for sub-cent prices", () => {
|
||||
expect(fmtPrice(0.005)).toBe("$0.0050/1M")
|
||||
})
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { errorIDs, visibleError } from "../../webview-ui/src/context/session-errors"
|
||||
import {
|
||||
errorIDs,
|
||||
preserveSessionErrors,
|
||||
visibleError,
|
||||
withoutResolvedSessionErrors,
|
||||
} from "../../webview-ui/src/context/session-errors"
|
||||
import type { Message } from "../../webview-ui/src/types/messages"
|
||||
|
||||
const base = {
|
||||
@@ -45,3 +50,32 @@ describe("visibleError", () => {
|
||||
expect(visibleError(messages, () => false)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("session error reconciliation", () => {
|
||||
const error = {
|
||||
name: "APIError",
|
||||
data: { message: "prompt_cache_breakpoint is not supported on this model", isRetryable: false },
|
||||
}
|
||||
|
||||
it("preserves transient errors across stale history replacements", () => {
|
||||
const user = { ...base, id: "message_1", role: "user" as const }
|
||||
const transient = { ...assistant("message_2", error), parentID: user.id, sessionErrorID: "event_1" }
|
||||
|
||||
expect(preserveSessionErrors([user, transient], [user])).toEqual([user, transient])
|
||||
})
|
||||
|
||||
it("replaces a transient error with its persisted assistant message", () => {
|
||||
const transient = { ...assistant("message_2", error), parentID: "message_1", sessionErrorID: "event_1" }
|
||||
const persisted = { ...assistant("message_3", error), parentID: "message_1" }
|
||||
|
||||
expect(preserveSessionErrors([transient], [persisted])).toEqual([persisted])
|
||||
expect(withoutResolvedSessionErrors([transient], [persisted])).toEqual([])
|
||||
})
|
||||
|
||||
it("deduplicates repeated delivery of the same session error event", () => {
|
||||
const first = { ...assistant("message_2", error), parentID: "message_1", sessionErrorID: "event_1" }
|
||||
const repeat = { ...assistant("message_3", error), parentID: "message_1", sessionErrorID: "event_1" }
|
||||
|
||||
expect(withoutResolvedSessionErrors([first], [repeat])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
RunStatus,
|
||||
PRStatus,
|
||||
AgentManagerPRStatusMessage,
|
||||
AgentManagerPRErrorMessage,
|
||||
AgentManagerProjectsMessage,
|
||||
AgentProjectSnapshot,
|
||||
ManagedSessionState,
|
||||
@@ -143,6 +144,7 @@ import {
|
||||
import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering"
|
||||
import { useTabScroll } from "./tab-scroll"
|
||||
import { DiffPanel } from "./DiffPanel"
|
||||
import { PRPanel } from "./pr/PRPanel"
|
||||
import { createRevertFile } from "./revert-file"
|
||||
import { FullScreenDiffView } from "../diff-viewer/FullScreenDiffView"
|
||||
import { createApplyToLocal } from "./apply-to-local"
|
||||
@@ -152,6 +154,8 @@ import { clearReviewComposer, createReviewComposer } from "../diff-viewer/review
|
||||
import type { SidebarSearchMenuRef } from "./SidebarSearchMenu"
|
||||
import { createSidebarSearch, type SidebarSearchItem } from "./sidebar-search"
|
||||
import { randomColor } from "./section-colors"
|
||||
import { createMarkdownRender } from "./review-preferences"
|
||||
import { createSidebarCollapse } from "./sidebar-collapse"
|
||||
import { createNewTaskDrafts } from "./new-task-drafts"
|
||||
import {
|
||||
buildTopLevelItems,
|
||||
@@ -170,11 +174,9 @@ import { DiffScopeControls } from "../diff-viewer/DiffScopeControls"
|
||||
import { scopeCapabilities } from "./diff-scope-state"
|
||||
import { createDiffReviewScope } from "./diff-review-scope"
|
||||
import { initialMessage, seedInitialVariant } from "./initial-message"
|
||||
import { createMarkdownRender } from "./review-preferences"
|
||||
import { createSidebarCollapse } from "./sidebar-collapse"
|
||||
import { SidebarToggleButton } from "./SidebarToggleButton"
|
||||
import { setTabWidths } from "./tab-widths"
|
||||
import { clampPanelWidth, createPanelResize, maxPanelWidth, minPanelWidth } from "./side-panel-layout"
|
||||
import { clampPanelWidth, createPanelResize, maxPanelWidth, minPanelWidth, SidePanel } from "./side-panel-layout"
|
||||
import { buildShortcutCategories } from "./shortcuts"
|
||||
import { tracker } from "./telemetry"
|
||||
import { createChatFocus, createPromptFocus, hasQuestionOption } from "./focus"
|
||||
@@ -196,7 +198,7 @@ interface SetupState {
|
||||
|
||||
/** Sidebar selection: LOCAL for local repo, worktree ID for a worktree, or null for an unassigned session. */
|
||||
type SidebarSelection = typeof LOCAL | string | null
|
||||
type SidePanel = "diff" | "pr" | "terminal" | null
|
||||
export type SidePanelState = SidePanel | null
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
|
||||
|
||||
import { parseBindingTokens } from "./keybind-tokens"
|
||||
@@ -288,8 +290,16 @@ const AgentManagerContent: Component = () => {
|
||||
let pendingSidebarWidth: number | undefined
|
||||
|
||||
const [history, setHistory] = createSignal(false)
|
||||
const [sidePanel, setSidePanel] = createSignal<SidePanel>(null)
|
||||
const diffOpen = () => sidePanel() === "diff"
|
||||
const [sidePanel, setSidePanel] = createSignal<SidePanelState>(null)
|
||||
const diffOpen = () => sidePanel() === SidePanel.Diff
|
||||
const prOpen = () => sidePanel() === SidePanel.PR
|
||||
const activePR = createMemo(() => {
|
||||
const selected = selection()
|
||||
if (!selected || selected === LOCAL) return undefined
|
||||
const pr = prStatuses()[selected]
|
||||
if (!pr) return undefined
|
||||
return { pr, selected, wt: worktrees().find((w) => w.id === selected) }
|
||||
})
|
||||
const diffs = createWorktreeDiffs(vscode)
|
||||
const diffDatas = diffs.diffDatas
|
||||
const diffLoading = diffs.diffLoading
|
||||
@@ -302,7 +312,7 @@ const AgentManagerContent: Component = () => {
|
||||
const showSideTerminal = () => {
|
||||
setHistory(false)
|
||||
setReviewActive(false)
|
||||
setSidePanel("terminal")
|
||||
setSidePanel(SidePanel.Terminal)
|
||||
}
|
||||
|
||||
const [reviewOpenByContext, setReviewOpenByContext] = createSignal<Record<string, boolean>>({})
|
||||
@@ -314,7 +324,6 @@ const AgentManagerContent: Component = () => {
|
||||
// Per-worktree git stats (diff additions/deletions, commits missing from origin)
|
||||
const worktreeStats = () => registry.active().worktreeStats()
|
||||
|
||||
// Per-worktree PR status data
|
||||
const prStatuses = () => registry.active().prStatuses()
|
||||
|
||||
const runStatuses = () => registry.active().runStatuses()
|
||||
@@ -392,7 +401,7 @@ const AgentManagerContent: Component = () => {
|
||||
const rememberPromptFocus = (focused: boolean) => {
|
||||
if (focused) focusMemory.set(focusKey(), "prompt")
|
||||
}
|
||||
const terminalVisible = () => sidePanel() === "terminal" && !history() && !reviewActive()
|
||||
const terminalVisible = () => sidePanel() === SidePanel.Terminal && !history() && !reviewActive()
|
||||
const focusOnDraftChange = () => {
|
||||
const key = focusKey()
|
||||
const owner = focusMemory.get(key)
|
||||
@@ -461,6 +470,15 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
createEffect(on(selection, () => cancelPendingDelete(), { defer: true }))
|
||||
createEffect(on(selection, () => clearReviewComposer(reviewComposer), { defer: true }))
|
||||
createEffect(
|
||||
on(
|
||||
selection,
|
||||
() => {
|
||||
if (!activePR()) setSidePanel((p) => (p === SidePanel.PR ? null : p))
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
onCleanup(() => clearTimeout(pendingDeleteTimer))
|
||||
|
||||
// Per-context tab memory lives in the active project's store: maps sidebar
|
||||
@@ -518,11 +536,17 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
const openWindow = metrics.click("open_worktree_window", "tab_toolbar", openWorktreeDirectory)
|
||||
|
||||
const togglePRPanel = () => {
|
||||
setHistory(false)
|
||||
if (reviewActive()) closeReviewTab()
|
||||
setSidePanel((prev) => (prev === SidePanel.PR ? null : SidePanel.PR))
|
||||
}
|
||||
|
||||
const openSelectedPR = () => {
|
||||
const sel = selection()
|
||||
if (!sel || sel === LOCAL || !prStatuses()[sel]) return
|
||||
metrics.track("open_pull_request", "keyboard_shortcut")
|
||||
vscode.postMessage({ type: "agentManager.openPR", worktreeId: sel })
|
||||
togglePRPanel()
|
||||
}
|
||||
|
||||
const runWorktree = (id: string, destination: TerminalDestination) => {
|
||||
@@ -1154,8 +1178,8 @@ const AgentManagerContent: Component = () => {
|
||||
} else if (msg.action === "toggleDiff") {
|
||||
if (reviewActive()) {
|
||||
closeReviewTab()
|
||||
setSidePanel("diff")
|
||||
} else setSidePanel((prev) => (prev === "diff" ? null : "diff"))
|
||||
setSidePanel(SidePanel.Diff)
|
||||
} else setSidePanel((prev) => (prev === SidePanel.Diff ? null : SidePanel.Diff))
|
||||
} else if (msg.action === "newTab") handleNewTabForCurrentSelection()
|
||||
else if (msg.action === "closeTab") closeActiveTab()
|
||||
else if (msg.action === "newWorktree") showNewWorktreeDialog()
|
||||
@@ -1316,6 +1340,7 @@ const AgentManagerContent: Component = () => {
|
||||
showToast({ variant: "error", title: t("agentManager.terminal.errorTitle"), description: message }),
|
||||
postMessage: (message) => vscode.postMessage(message as never),
|
||||
onCreated: (contextKey, terminalId) => appendToTabOrder(contextKey, terminalId),
|
||||
|
||||
onSideClosed: (_contextKey, terminalId) => forgetTerminalFocus(terminalId),
|
||||
onScriptRunning: (contextKey, terminalId) => {
|
||||
if (terms.sideKey() !== contextKey) return
|
||||
@@ -1383,7 +1408,7 @@ const AgentManagerContent: Component = () => {
|
||||
if (!isActivePayload(ev.projectId)) return
|
||||
// Close diff/review panels — nothing to show during setup.
|
||||
// Terminal panels keep live setup output, so they stay open.
|
||||
if (sidePanel() === "diff") setSidePanel(null)
|
||||
if (sidePanel() === SidePanel.Diff) setSidePanel(null)
|
||||
setReviewActive(false)
|
||||
setSetup({ active: true, message: ev.message, branch: ev.branch, worktreeId: ev.worktreeId })
|
||||
}
|
||||
@@ -1533,6 +1558,15 @@ const AgentManagerContent: Component = () => {
|
||||
managedSession: focusManagedSession,
|
||||
})
|
||||
|
||||
if (msg.type === "agentManager.prError") {
|
||||
const ev = msg as AgentManagerPRErrorMessage
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: t(`agentManager.pr.error.${ev.error}.title`),
|
||||
description: t(`agentManager.pr.error.${ev.error}.description`),
|
||||
})
|
||||
}
|
||||
|
||||
if (projectLive.apply(msg)) return
|
||||
})
|
||||
|
||||
@@ -1982,7 +2016,7 @@ const AgentManagerContent: Component = () => {
|
||||
|
||||
const sideCtl = createSideTerminal({
|
||||
handlers: termHandlers,
|
||||
visible: () => sidePanel() === "terminal" && !history() && !reviewActive(),
|
||||
visible: () => sidePanel() === SidePanel.Terminal && !history() && !reviewActive(),
|
||||
focusedId: () => terms.sideFocusedId(),
|
||||
count: () => terms.sidesForContext(terms.sideKey()).length,
|
||||
isScript: terms.isScript,
|
||||
@@ -2115,7 +2149,7 @@ const AgentManagerContent: Component = () => {
|
||||
const closeActiveTab = () => {
|
||||
// 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() === "terminal" && terms.sideFocusedId()) {
|
||||
if (sidePanel() === SidePanel.Terminal && terms.sideFocusedId()) {
|
||||
if (sideCtl.close()) return
|
||||
}
|
||||
if (termHandlers.closeFocused()) {
|
||||
@@ -2171,10 +2205,10 @@ const AgentManagerContent: Component = () => {
|
||||
})
|
||||
if (reviewActive()) {
|
||||
closeReviewTab()
|
||||
setSidePanel("diff")
|
||||
setSidePanel(SidePanel.Diff)
|
||||
return
|
||||
}
|
||||
setSidePanel((prev) => (prev === "diff" ? null : "diff"))
|
||||
setSidePanel((prev) => (prev === SidePanel.Diff ? null : SidePanel.Diff))
|
||||
}
|
||||
|
||||
const renderTabById = (id: string) =>
|
||||
@@ -2370,8 +2404,11 @@ const AgentManagerContent: Component = () => {
|
||||
reviewActive={reviewActive}
|
||||
onToggleDiff={toggleDiffPanel}
|
||||
onToggleReview={metrics.click("fullscreen_review", "tab_toolbar", toggleReviewTab)}
|
||||
prStatus={() => activePR()?.pr}
|
||||
prOpen={prOpen}
|
||||
onTogglePR={togglePRPanel}
|
||||
terminalDestination={sideCtl.destination}
|
||||
terminalDestinationActive={() => sidePanel() === "terminal"}
|
||||
terminalDestinationActive={() => sidePanel() === SidePanel.Terminal}
|
||||
terminalKeybind={() => kb().showTerminal ?? ""}
|
||||
onTerminalDestinationOpen={() => {
|
||||
cancelAmbientSetup()
|
||||
@@ -2559,7 +2596,7 @@ const AgentManagerContent: Component = () => {
|
||||
/>
|
||||
</Show>
|
||||
<div class="am-diff-panel-wrapper">
|
||||
<Show when={sidePanel() === "diff"}>
|
||||
<Show when={sidePanel() === SidePanel.Diff}>
|
||||
<DiffPanel
|
||||
diffs={reviewDiffs()}
|
||||
loading={diffLoading()}
|
||||
@@ -2594,10 +2631,29 @@ const AgentManagerContent: Component = () => {
|
||||
activeTerminalId={terms.activeId()}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={sidePanel() === SidePanel.PR && activePR()}>
|
||||
{(() => {
|
||||
const data = activePR()!
|
||||
return (
|
||||
<PRPanel
|
||||
pr={data.pr}
|
||||
worktree={data.wt}
|
||||
onClose={() => setSidePanel(null)}
|
||||
onOpenExternal={() =>
|
||||
vscode.postMessage({
|
||||
type: "agentManager.openPR",
|
||||
worktreeId: data.selected,
|
||||
url: data.pr.url,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</Show>
|
||||
<SideTerminalPanel
|
||||
state={terms}
|
||||
contextKey={terms.sideKey}
|
||||
visible={() => sidePanel() === "terminal"}
|
||||
visible={() => sidePanel() === SidePanel.Terminal}
|
||||
nextKeybind={kb().nextTerminal ?? ""}
|
||||
closeKeybind={kb().closeTab ?? ""}
|
||||
onFocusPrompt={prompt.focus}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
closestCenter,
|
||||
type DragEvent,
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import type { LocalGitStats, RunStatus, WorktreeGitStats } from "../src/types/messages"
|
||||
import type { LocalGitStats, RunStatus, WorktreeGitStats, PRStatus } from "../src/types/messages"
|
||||
import type { LanguageContextValue } from "../src/context/language"
|
||||
import { LOCAL } from "./navigate"
|
||||
import { ConstrainDragYAxis } from "../src/components/chat/TabDnd"
|
||||
@@ -55,6 +55,9 @@ export interface TabBarProps {
|
||||
reviewActive: () => boolean
|
||||
onToggleDiff: () => void
|
||||
onToggleReview: () => void
|
||||
prStatus: () => PRStatus | undefined
|
||||
prOpen: () => boolean
|
||||
onTogglePR: () => void
|
||||
terminalDestination: () => TerminalDestination
|
||||
terminalDestinationActive: () => boolean
|
||||
terminalKeybind: () => string
|
||||
@@ -215,6 +218,20 @@ export const TabBar: Component<TabBarProps> = (props) => (
|
||||
)
|
||||
})()}
|
||||
</Show>
|
||||
<Show when={props.prStatus()}>
|
||||
{(pr) => (
|
||||
<Tooltip value={`PR #${pr().number}`} placement="bottom">
|
||||
<IconButton
|
||||
icon="pull-request"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={`PR #${pr().number}`}
|
||||
class={props.prOpen() ? "am-tab-diff-btn-active" : ""}
|
||||
onClick={props.onTogglePR}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Show>
|
||||
<TooltipKeybind
|
||||
title={props.t("agentManager.diff.toggle")}
|
||||
keybind={props.bindings().toggleDiff ?? ""}
|
||||
@@ -226,14 +243,19 @@ export const TabBar: Component<TabBarProps> = (props) => (
|
||||
title={props.t("agentManager.diff.toggle")}
|
||||
>
|
||||
<Icon name="layers" size="small" />
|
||||
<Show when={hasChanges()}>
|
||||
<span class="am-diff-toggle-stats">
|
||||
<Show when={stats()!.files > 0}>
|
||||
<span class="am-stat-files">{stats()!.files}f</span>
|
||||
<Show when={props.prStatus()}>
|
||||
{(pr) => (
|
||||
<Show when={pr().additions > 0 || pr().deletions > 0}>
|
||||
<span class="am-diff-toggle-stats">
|
||||
<Show when={pr().additions > 0}>
|
||||
<span class="am-stat-additions">+{pr().additions}</span>
|
||||
</Show>
|
||||
<Show when={pr().deletions > 0}>
|
||||
<span class="am-stat-deletions">−{pr().deletions}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
<span class="am-stat-additions">+{stats()!.additions}</span>
|
||||
<span class="am-stat-deletions">−{stats()!.deletions}</span>
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</button>
|
||||
</TooltipKeybind>
|
||||
|
||||
@@ -10,7 +10,8 @@ import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
|
||||
import { HoverCard } from "@kilocode/kilo-ui/hover-card"
|
||||
import { ContextMenu } from "@kilocode/kilo-ui/context-menu"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import type { WorktreeState, WorktreeGitStats, PRStatus, SectionState, RunStatus } from "../src/types/messages"
|
||||
import type { WorktreeState, WorktreeGitStats, SectionState, RunStatus } from "../src/types/messages"
|
||||
import type { PRStatus } from "../src/types/messages"
|
||||
import { colorCss } from "./section-colors"
|
||||
import { useLanguage } from "../src/context/language"
|
||||
import { formatRelativeDate } from "../src/utils/date"
|
||||
@@ -57,7 +58,7 @@ interface WorktreeItemProps {
|
||||
/** Keybinding string for the open-in-vscode action. */
|
||||
openKeybind: string
|
||||
/** PR status for this worktree's branch, or null if no PR. */
|
||||
pr?: PRStatus | null
|
||||
pr?: PRStatus
|
||||
runStatus?: RunStatus
|
||||
/** Callback when the PR badge is clicked. */
|
||||
onOpenPR?: () => void
|
||||
@@ -94,14 +95,6 @@ const hasStats = (s: WorktreeGitStats | undefined): s is WorktreeGitStats =>
|
||||
* and review results are conveyed by a separate status icon (see prBadgeIndicator)
|
||||
* so a failing check is not mistaken for a closed PR.
|
||||
*/
|
||||
export function prAccentColor(pr: PRStatus): string {
|
||||
if (pr.state === "draft") return "var(--text-weaker)"
|
||||
if (pr.state === "merged") return "#a78bfa"
|
||||
if (pr.state === "closed") return "#f87171"
|
||||
if (pr.checks.status === "pending") return "#fbbf24"
|
||||
return "#34d399"
|
||||
}
|
||||
|
||||
/** True while an open PR's checks are still running — drives the pulsing amber badge. */
|
||||
export function prChecksRunning(pr: PRStatus): boolean {
|
||||
return pr.state === "open" && pr.checks.status === "pending"
|
||||
@@ -349,18 +342,30 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
|
||||
}
|
||||
>
|
||||
{(pr) => {
|
||||
const accent = () => prAccentColor(pr())
|
||||
const indicator = () => prBadgeIndicator(pr())
|
||||
return (
|
||||
<span
|
||||
class="am-pr-badge"
|
||||
style={{ "--pr-accent": accent() }}
|
||||
data-pending={prChecksRunning(pr()) ? "" : undefined}
|
||||
classList={{
|
||||
"am-pr-accent-draft": pr().state === "draft",
|
||||
"am-pr-accent-merged": pr().state === "merged",
|
||||
"am-pr-accent-closed": pr().state === "closed",
|
||||
"am-pr-accent-pending": pr().state === "open" && pr().checks.status === "pending",
|
||||
"am-pr-accent-open": pr().state === "open" && pr().checks.status !== "pending",
|
||||
"am-pr-badge-pending": prChecksRunning(pr()),
|
||||
}}
|
||||
onClick={handleOpenPR}
|
||||
>
|
||||
<Switch fallback={<Icon name="branch" size="small" />}>
|
||||
<Icon name="pull-request" size="small" />
|
||||
<span class="am-pr-badge-number">#{pr().number}</span>
|
||||
<Switch>
|
||||
<Match when={indicator() === "failure"}>
|
||||
<Icon name="circle-x" size="small" class="am-pr-badge-status" data-status="failure" />
|
||||
<Icon
|
||||
name="circle-x-outline"
|
||||
size="small"
|
||||
class="am-pr-badge-status"
|
||||
data-status="failure"
|
||||
/>
|
||||
</Match>
|
||||
<Match when={indicator() === "changes"}>
|
||||
<Icon name="warning" size="small" class="am-pr-badge-status" data-status="changes" />
|
||||
@@ -374,7 +379,6 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
<span class="am-pr-badge-number">#{pr().number}</span>
|
||||
</span>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -840,7 +840,7 @@ html[data-theme="kilo-vscode"]
|
||||
}
|
||||
|
||||
.am-run-badge[data-run-state="stopping"] {
|
||||
--am-run-accent: #fbbf24;
|
||||
--am-run-accent: var(--vscode-charts-yellow, #fbbf24);
|
||||
}
|
||||
|
||||
.am-run-badge[data-run-state="success"] {
|
||||
@@ -848,7 +848,7 @@ html[data-theme="kilo-vscode"]
|
||||
}
|
||||
|
||||
.am-run-badge[data-run-state="error"] {
|
||||
--am-run-accent: #f87171;
|
||||
--am-run-accent: var(--vscode-testing-iconFailed, #f87171);
|
||||
}
|
||||
|
||||
@keyframes am-run-pulse {
|
||||
@@ -898,24 +898,44 @@ html[data-theme="kilo-vscode"]
|
||||
}
|
||||
|
||||
.am-stat-additions {
|
||||
color: #34d399;
|
||||
color: var(--vscode-testing-iconPassed, #34d399);
|
||||
}
|
||||
|
||||
.am-stat-deletions {
|
||||
color: #f87171;
|
||||
color: var(--vscode-testing-iconFailed, #f87171);
|
||||
}
|
||||
|
||||
/* PR badge accent colors via classList */
|
||||
.am-pr-accent-open,
|
||||
.am-pr-panel-badge.am-pr-accent-open {
|
||||
--am-pr-accent: var(--vscode-testing-iconPassed, #34d399);
|
||||
}
|
||||
.am-pr-accent-pending,
|
||||
.am-pr-panel-badge.am-pr-accent-pending {
|
||||
--am-pr-accent: var(--vscode-charts-yellow, #fbbf24);
|
||||
}
|
||||
.am-pr-accent-draft,
|
||||
.am-pr-panel-badge.am-pr-accent-draft {
|
||||
--am-pr-accent: var(--text-weaker);
|
||||
}
|
||||
.am-pr-accent-merged,
|
||||
.am-pr-panel-badge.am-pr-accent-merged {
|
||||
--am-pr-accent: var(--vscode-charts-purple, var(--vscode-foreground));
|
||||
}
|
||||
.am-pr-accent-closed,
|
||||
.am-pr-panel-badge.am-pr-accent-closed {
|
||||
--am-pr-accent: var(--vscode-testing-iconFailed, #f87171);
|
||||
}
|
||||
|
||||
/* PR pill badge — second row inside am-wt-row2.
|
||||
Uses --pr-accent custom property (set inline) to derive all colors. */
|
||||
.am-pr-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 7px 2px 4px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: color-mix(in srgb, var(--pr-accent) 12%, transparent);
|
||||
color: var(--pr-accent);
|
||||
background: color-mix(in srgb, var(--am-pr-accent) 12%, transparent);
|
||||
color: var(--am-pr-accent);
|
||||
cursor: pointer;
|
||||
font-size: var(--kilo-font-size-11);
|
||||
font-weight: 500;
|
||||
@@ -924,34 +944,34 @@ html[data-theme="kilo-vscode"]
|
||||
margin-left: auto;
|
||||
}
|
||||
.am-pr-badge:hover {
|
||||
background: color-mix(in srgb, var(--pr-accent) 25%, transparent);
|
||||
background: color-mix(in srgb, var(--am-pr-accent) 25%, transparent);
|
||||
}
|
||||
.am-pr-badge [data-component="icon"] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--pr-accent);
|
||||
color: var(--am-pr-accent);
|
||||
}
|
||||
.am-pr-badge-number {
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.am-pr-badge:hover .am-pr-badge-number {
|
||||
color: var(--pr-accent);
|
||||
color: var(--am-pr-accent);
|
||||
}
|
||||
/* Status icon overlaid on the badge — colored by CI/review result, independent
|
||||
of the badge's PR-state accent color. */
|
||||
.am-pr-badge-status[data-status="failure"] {
|
||||
color: #ef4444;
|
||||
color: var(--vscode-testing-iconFailed, #f87171);
|
||||
}
|
||||
.am-pr-badge-status[data-status="changes"] {
|
||||
color: #fbbf24;
|
||||
color: var(--vscode-charts-yellow, #fbbf24);
|
||||
}
|
||||
.am-pr-badge-status[data-status="approved"] {
|
||||
color: #34d399;
|
||||
color: var(--vscode-testing-iconPassed, #34d399);
|
||||
}
|
||||
|
||||
/* Checks still running — pulse the whole amber badge. */
|
||||
.am-pr-badge[data-pending] {
|
||||
.am-pr-badge.am-pr-badge-pending {
|
||||
animation: am-pr-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes am-pr-pulse {
|
||||
@@ -965,17 +985,17 @@ html[data-theme="kilo-vscode"]
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.am-pr-badge[data-pending] {
|
||||
.am-pr-badge.am-pr-badge-pending {
|
||||
animation: none;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.am-worktree-item-active .am-pr-badge {
|
||||
background: color-mix(in srgb, var(--pr-accent) 18%, transparent);
|
||||
background: color-mix(in srgb, var(--am-pr-accent) 18%, transparent);
|
||||
}
|
||||
.am-worktree-item-active .am-pr-badge:hover {
|
||||
background: color-mix(in srgb, var(--pr-accent) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--am-pr-accent) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Clickable link icon in hover card to open PR on GitHub */
|
||||
@@ -996,35 +1016,35 @@ html[data-theme="kilo-vscode"]
|
||||
}
|
||||
|
||||
.am-worktree-item-active .am-stat-files {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
.am-worktree-item-active .am-stat-additions {
|
||||
color: #6ee7b7;
|
||||
color: var(--vscode-testing-iconPassed, #6ee7b7);
|
||||
}
|
||||
|
||||
.am-worktree-item-active .am-stat-deletions {
|
||||
color: #fca5a5;
|
||||
color: var(--vscode-testing-iconFailed, #fca5a5);
|
||||
}
|
||||
|
||||
.am-worktree-commits {
|
||||
color: #34d399;
|
||||
color: var(--vscode-testing-iconPassed, #34d399);
|
||||
}
|
||||
|
||||
.am-worktree-behind {
|
||||
color: #f87171;
|
||||
color: var(--vscode-testing-iconFailed, #f87171);
|
||||
}
|
||||
|
||||
.am-worktree-item-active .am-worktree-commits {
|
||||
color: #6ee7b7;
|
||||
color: var(--vscode-testing-iconPassed, #6ee7b7);
|
||||
}
|
||||
|
||||
.am-worktree-item-active .am-worktree-behind {
|
||||
color: #fca5a5;
|
||||
color: var(--vscode-testing-iconFailed, #fca5a5);
|
||||
}
|
||||
|
||||
.am-local-item-active .am-worktree-behind {
|
||||
color: #fca5a5;
|
||||
color: var(--vscode-testing-iconFailed, #fca5a5);
|
||||
}
|
||||
|
||||
.am-local-item:hover .am-worktree-stats,
|
||||
@@ -1699,7 +1719,7 @@ body.am-wt-dragging-active * {
|
||||
}
|
||||
|
||||
.am-sidebar-search-status {
|
||||
color: #fbbf24;
|
||||
color: var(--vscode-charts-yellow, #fbbf24);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -1794,9 +1814,10 @@ body.am-wt-dragging-active * {
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
.am-diff-toggle-btn.am-tab-diff-btn-active {
|
||||
background: var(--surface-interactive-base) !important;
|
||||
color: var(--text-base) !important;
|
||||
.am-diff-toggle-btn.am-tab-diff-btn-active,
|
||||
.am-diff-toggle-btn.am-tab-diff-btn-active:hover {
|
||||
background: var(--surface-interactive-base);
|
||||
color: var(--text-on-interactive-base);
|
||||
}
|
||||
|
||||
.am-diff-toggle-btn.am-diff-toggle-has-changes {
|
||||
@@ -1810,20 +1831,24 @@ body.am-wt-dragging-active * {
|
||||
}
|
||||
|
||||
.am-diff-toggle-btn.am-tab-diff-btn-active .am-stat-files {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
.am-diff-toggle-btn.am-tab-diff-btn-active .am-stat-additions {
|
||||
color: #6ee7b7;
|
||||
color: var(--vscode-testing-iconPassed, #6ee7b7);
|
||||
}
|
||||
|
||||
.am-diff-toggle-btn.am-tab-diff-btn-active .am-stat-deletions {
|
||||
color: #fca5a5;
|
||||
color: var(--vscode-testing-iconFailed, #fca5a5);
|
||||
}
|
||||
|
||||
.am-tab-diff-btn-active {
|
||||
background: var(--surface-interactive-base) !important;
|
||||
color: var(--text-base) !important;
|
||||
color: var(--text-on-interactive-base) !important;
|
||||
}
|
||||
|
||||
.am-tab-diff-btn-active [data-slot="icon-svg"] {
|
||||
color: var(--text-on-interactive-base) !important;
|
||||
}
|
||||
|
||||
.am-detail-content {
|
||||
@@ -2947,9 +2972,10 @@ body.am-wt-dragging-active * {
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
.am-nv-pill-active {
|
||||
background: var(--border-focus, #007fd4) !important;
|
||||
color: #fff !important;
|
||||
.am-nv-pill-active,
|
||||
.am-nv-pill-active:hover {
|
||||
background: var(--border-focus, #007fd4);
|
||||
color: var(--text-on-interactive-base);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
|
||||
+8
-3
@@ -14,7 +14,7 @@ export const dict = {
|
||||
"agentManager.worktree.new": "Worktree جديد",
|
||||
"agentManager.worktree.setupScript": "سكربت إعداد Worktree",
|
||||
"agentManager.worktree.delete": "حذف Worktree",
|
||||
"agentManager.worktree.confirmDelete": "Delete?",
|
||||
"agentManager.worktree.confirmDelete": "حذف؟",
|
||||
"agentManager.worktree.stale": "قديم",
|
||||
"agentManager.worktree.staleTooltip": "مفقود على القرص أو لم يعد git worktree يتتبعه",
|
||||
"agentManager.worktree.removeStale": "إزالة Worktree القديم",
|
||||
@@ -104,8 +104,8 @@ export const dict = {
|
||||
"agentManager.run.options": "خيارات التشغيل",
|
||||
"agentManager.run.configure": "تكوين سكربت التشغيل",
|
||||
"agentManager.shortcuts.openAgentManager": "فتح Agent Manager",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
|
||||
"agentManager.shortcuts.cycleAgentMode": "وضع الوكيل التالي",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "وضع الوكيل السابق",
|
||||
"agentManager.shortcuts.showShortcuts": "إظهار اختصارات لوحة المفاتيح",
|
||||
"agentManager.dialog.removeStaleWorktree.title": "إزالة Worktree القديم",
|
||||
"agentManager.dialog.removeStaleWorktree.messagePre": "إزالة Worktree القديم ",
|
||||
@@ -211,4 +211,9 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "لم يتم العثور على فروع.",
|
||||
"agentManager.import.noBranchesHint": "الصق رابط PR أعلاه أو أنشئ Worktree جديدًا.",
|
||||
"agentManager.import.failed": "فشل الاستيراد",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "مصادقة GitHub مطلوبة",
|
||||
"agentManager.pr.error.gh_auth.description": "شغّل 'gh auth login' في الطرفية لاستعادة حالة PR.",
|
||||
"agentManager.pr.error.gh_missing.title": "لم يتم تثبيت GitHub CLI",
|
||||
"agentManager.pr.error.gh_missing.description": "قم بتثبيت GitHub CLI لتفعيل حالة PR.",
|
||||
}
|
||||
|
||||
+8
-3
@@ -14,7 +14,7 @@ export const dict = {
|
||||
"agentManager.worktree.new": "Novo Worktree",
|
||||
"agentManager.worktree.setupScript": "Script de configuração do Worktree",
|
||||
"agentManager.worktree.delete": "Excluir Worktree",
|
||||
"agentManager.worktree.confirmDelete": "Delete?",
|
||||
"agentManager.worktree.confirmDelete": "Excluir?",
|
||||
"agentManager.worktree.stale": "Obsoleto",
|
||||
"agentManager.worktree.staleTooltip": "Ausente no disco ou não é mais rastreado pelo git worktree",
|
||||
"agentManager.worktree.removeStale": "Remover Worktree obsoleto",
|
||||
@@ -106,8 +106,8 @@ export const dict = {
|
||||
"agentManager.run.options": "Opções de execução",
|
||||
"agentManager.run.configure": "Configurar script de execução",
|
||||
"agentManager.shortcuts.openAgentManager": "Abrir Agent Manager",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Próximo modo de agente",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Modo de agente anterior",
|
||||
"agentManager.shortcuts.showShortcuts": "Mostrar atalhos de teclado",
|
||||
"agentManager.dialog.removeStaleWorktree.title": "Remover Worktree obsoleto",
|
||||
"agentManager.dialog.removeStaleWorktree.messagePre": "Remover Worktree obsoleto ",
|
||||
@@ -215,4 +215,9 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "Nenhum branch encontrado.",
|
||||
"agentManager.import.noBranchesHint": "Cole uma URL de PR acima ou crie um novo Worktree.",
|
||||
"agentManager.import.failed": "Falha na importação",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "Autenticação do GitHub necessária",
|
||||
"agentManager.pr.error.gh_auth.description": "Execute 'gh auth login' no terminal para restaurar o status do PR.",
|
||||
"agentManager.pr.error.gh_missing.title": "GitHub CLI não instalado",
|
||||
"agentManager.pr.error.gh_missing.description": "Instale o GitHub CLI para habilitar o status do PR.",
|
||||
}
|
||||
|
||||
+9
-4
@@ -14,7 +14,7 @@ export const dict = {
|
||||
"agentManager.worktree.new": "Novi Worktree",
|
||||
"agentManager.worktree.setupScript": "Skripta za postavljanje Worktree-a",
|
||||
"agentManager.worktree.delete": "Obriši Worktree",
|
||||
"agentManager.worktree.confirmDelete": "Delete?",
|
||||
"agentManager.worktree.confirmDelete": "Izbrisati?",
|
||||
"agentManager.worktree.stale": "Zastario",
|
||||
"agentManager.worktree.staleTooltip": "Nedostaje na disku ili ga git worktree više ne prati",
|
||||
"agentManager.worktree.removeStale": "Ukloni zastarjeli Worktree",
|
||||
@@ -106,8 +106,8 @@ export const dict = {
|
||||
"agentManager.run.options": "Opcije pokretanja",
|
||||
"agentManager.run.configure": "Konfiguriši skriptu za pokretanje",
|
||||
"agentManager.shortcuts.openAgentManager": "Otvori Agent Manager",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Sljedeći način rada agenta",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Prethodni način rada agenta",
|
||||
"agentManager.shortcuts.showShortcuts": "Prikaži prečice na tastaturi",
|
||||
"agentManager.dialog.removeStaleWorktree.title": "Ukloni zastarjeli Worktree",
|
||||
"agentManager.dialog.removeStaleWorktree.messagePre": "Ukloni zastarjeli Worktree ",
|
||||
@@ -203,7 +203,7 @@ export const dict = {
|
||||
"agentManager.review.imageUnavailable": "Pregled slike nije dostupan za ovaj snimak sesije.",
|
||||
"agentManager.review.endOfLongDiff": "Došli ste do kraja!",
|
||||
|
||||
"agentManager.import.pullRequest": "Pull Request",
|
||||
"agentManager.import.pullRequest": "Zahtjev za povlačenje",
|
||||
"agentManager.import.pastePrUrl": "Zalijepite PR URL...",
|
||||
"agentManager.import.open": "Otvori",
|
||||
"agentManager.import.branches": "Branchevi",
|
||||
@@ -214,4 +214,9 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "Nisu pronađeni branchevi.",
|
||||
"agentManager.import.noBranchesHint": "Zalijepite PR URL iznad ili kreirajte novi Worktree.",
|
||||
"agentManager.import.failed": "Uvoz neuspješan",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "Potrebna GitHub autentikacija",
|
||||
"agentManager.pr.error.gh_auth.description": "Pokrenite 'gh auth login' u terminalu da vratite status PR-a.",
|
||||
"agentManager.pr.error.gh_missing.title": "GitHub CLI nije instaliran",
|
||||
"agentManager.pr.error.gh_missing.description": "Instalirajte GitHub CLI da omogućite status PR-a.",
|
||||
}
|
||||
|
||||
+10
-5
@@ -14,7 +14,7 @@ export const dict = {
|
||||
"agentManager.worktree.new": "Nyt Worktree",
|
||||
"agentManager.worktree.setupScript": "Worktree-opsætningsscript",
|
||||
"agentManager.worktree.delete": "Slet Worktree",
|
||||
"agentManager.worktree.confirmDelete": "Delete?",
|
||||
"agentManager.worktree.confirmDelete": "Slet?",
|
||||
"agentManager.worktree.stale": "Forældet",
|
||||
"agentManager.worktree.staleTooltip": "Mangler på disken eller spores ikke længere af git worktree",
|
||||
"agentManager.worktree.removeStale": "Fjern forældet Worktree",
|
||||
@@ -107,8 +107,8 @@ export const dict = {
|
||||
"agentManager.run.options": "Kørselsindstillinger",
|
||||
"agentManager.run.configure": "Konfigurer kørselsscript",
|
||||
"agentManager.shortcuts.openAgentManager": "Åbn Agent Manager",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Næste agenttilstand",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Forrige agenttilstand",
|
||||
"agentManager.shortcuts.showShortcuts": "Vis tastaturgenveje",
|
||||
"agentManager.dialog.removeStaleWorktree.title": "Fjern forældet Worktree",
|
||||
"agentManager.dialog.removeStaleWorktree.messagePre": "Fjern forældet Worktree ",
|
||||
@@ -205,10 +205,10 @@ export const dict = {
|
||||
"Forhåndsvisning af billedet er ikke tilgængelig for dette snapshot af sessionen.",
|
||||
"agentManager.review.endOfLongDiff": "Du nåede slutningen!",
|
||||
|
||||
"agentManager.import.pullRequest": "Pull Request",
|
||||
"agentManager.import.pullRequest": "Pull request",
|
||||
"agentManager.import.pastePrUrl": "Indsæt PR URL...",
|
||||
"agentManager.import.open": "Åbn",
|
||||
"agentManager.import.branches": "Branches",
|
||||
"agentManager.import.branches": "Grene",
|
||||
"agentManager.import.selectBranch": "Vælg branch...",
|
||||
"agentManager.import.loading": "Indlæser...",
|
||||
"agentManager.import.loadingBranches": "Indlæser branches...",
|
||||
@@ -216,4 +216,9 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "Ingen branches fundet.",
|
||||
"agentManager.import.noBranchesHint": "Indsæt en PR URL ovenfor eller opret et nyt Worktree.",
|
||||
"agentManager.import.failed": "Import mislykkedes",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "GitHub-godkendelse påkrævet",
|
||||
"agentManager.pr.error.gh_auth.description": "Kør 'gh auth login' i din terminal for at gendanne PR-status.",
|
||||
"agentManager.pr.error.gh_missing.title": "GitHub CLI er ikke installeret",
|
||||
"agentManager.pr.error.gh_missing.description": "Installer GitHub CLI for at aktivere PR-status.",
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export const dict = {
|
||||
"agentManager.worktree.new": "Neuer Worktree",
|
||||
"agentManager.worktree.setupScript": "Worktree-Einrichtungsskript",
|
||||
"agentManager.worktree.delete": "Worktree löschen",
|
||||
"agentManager.worktree.confirmDelete": "Delete?",
|
||||
"agentManager.worktree.confirmDelete": "Löschen?",
|
||||
"agentManager.worktree.stale": "Veraltet",
|
||||
"agentManager.worktree.staleTooltip": "Fehlt auf der Festplatte oder wird nicht mehr von git worktree verfolgt",
|
||||
"agentManager.worktree.removeStale": "Veralteten Worktree entfernen",
|
||||
@@ -108,8 +108,8 @@ export const dict = {
|
||||
"agentManager.run.options": "Ausführungsoptionen",
|
||||
"agentManager.run.configure": "Ausführungsskript konfigurieren",
|
||||
"agentManager.shortcuts.openAgentManager": "Agent Manager öffnen",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Nächster Agentenmodus",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Vorheriger Agentenmodus",
|
||||
"agentManager.shortcuts.showShortcuts": "Tastenkürzel anzeigen",
|
||||
"agentManager.dialog.removeStaleWorktree.title": "Veralteten Worktree entfernen",
|
||||
"agentManager.dialog.removeStaleWorktree.messagePre": "Veralteten Worktree entfernen ",
|
||||
@@ -208,7 +208,7 @@ export const dict = {
|
||||
"agentManager.import.pullRequest": "Pull Request",
|
||||
"agentManager.import.pastePrUrl": "PR-URL einfügen...",
|
||||
"agentManager.import.open": "Öffnen",
|
||||
"agentManager.import.branches": "Branches",
|
||||
"agentManager.import.branches": "Zweige",
|
||||
"agentManager.import.selectBranch": "Branch auswählen...",
|
||||
"agentManager.import.loading": "Laden...",
|
||||
"agentManager.import.loadingBranches": "Branches werden geladen...",
|
||||
@@ -216,4 +216,10 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "Keine Branches gefunden.",
|
||||
"agentManager.import.noBranchesHint": "Fügen Sie oben eine PR-URL ein oder erstellen Sie einen neuen Worktree.",
|
||||
"agentManager.import.failed": "Import fehlgeschlagen",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "GitHub-Authentifizierung erforderlich",
|
||||
"agentManager.pr.error.gh_auth.description":
|
||||
"Führen Sie 'gh auth login' in Ihrem Terminal aus, um den PR-Status wiederherzustellen.",
|
||||
"agentManager.pr.error.gh_missing.title": "GitHub CLI nicht installiert",
|
||||
"agentManager.pr.error.gh_missing.description": "Installieren Sie die GitHub CLI, um den PR-Status zu aktivieren.",
|
||||
}
|
||||
|
||||
@@ -217,4 +217,8 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "No branches found.",
|
||||
"agentManager.import.noBranchesHint": "Paste a PR URL above or create a new worktree.",
|
||||
"agentManager.import.failed": "Import failed",
|
||||
"agentManager.pr.error.gh_auth.title": "GitHub authentication required",
|
||||
"agentManager.pr.error.gh_auth.description": "Run 'gh auth login' in your terminal to restore PR status.",
|
||||
"agentManager.pr.error.gh_missing.title": "GitHub CLI not installed",
|
||||
"agentManager.pr.error.gh_missing.description": "Install the GitHub CLI to enable PR status.",
|
||||
}
|
||||
|
||||
+10
-4
@@ -14,7 +14,7 @@ export const dict = {
|
||||
"agentManager.worktree.new": "Nuevo Worktree",
|
||||
"agentManager.worktree.setupScript": "Script de configuración de Worktree",
|
||||
"agentManager.worktree.delete": "Eliminar Worktree",
|
||||
"agentManager.worktree.confirmDelete": "Delete?",
|
||||
"agentManager.worktree.confirmDelete": "¿Eliminar?",
|
||||
"agentManager.worktree.stale": "Obsoleto",
|
||||
"agentManager.worktree.staleTooltip": "No está en disco o ya no está seguido por git worktree",
|
||||
"agentManager.worktree.removeStale": "Eliminar Worktree obsoleto",
|
||||
@@ -107,8 +107,8 @@ export const dict = {
|
||||
"agentManager.run.options": "Opciones de ejecución",
|
||||
"agentManager.run.configure": "Configurar script de ejecución",
|
||||
"agentManager.shortcuts.openAgentManager": "Abrir Agent Manager",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Siguiente modo de agente",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Modo de agente anterior",
|
||||
"agentManager.shortcuts.showShortcuts": "Mostrar atajos de teclado",
|
||||
"agentManager.dialog.removeStaleWorktree.title": "Eliminar Worktree obsoleto",
|
||||
"agentManager.dialog.removeStaleWorktree.messagePre": "¿Eliminar Worktree obsoleto ",
|
||||
@@ -208,7 +208,7 @@ export const dict = {
|
||||
"agentManager.import.pullRequest": "Pull Request",
|
||||
"agentManager.import.pastePrUrl": "Pegar URL del PR...",
|
||||
"agentManager.import.open": "Abrir",
|
||||
"agentManager.import.branches": "Branches",
|
||||
"agentManager.import.branches": "Ramas",
|
||||
"agentManager.import.selectBranch": "Seleccionar branch...",
|
||||
"agentManager.import.loading": "Cargando...",
|
||||
"agentManager.import.loadingBranches": "Cargando branches...",
|
||||
@@ -216,4 +216,10 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "No se encontraron branches.",
|
||||
"agentManager.import.noBranchesHint": "Pega una URL de PR arriba o crea un nuevo Worktree.",
|
||||
"agentManager.import.failed": "Error en la importación",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "Se requiere autenticación de GitHub",
|
||||
"agentManager.pr.error.gh_auth.description":
|
||||
"Ejecuta 'gh auth login' en tu terminal para restaurar el estado del PR.",
|
||||
"agentManager.pr.error.gh_missing.title": "GitHub CLI no está instalado",
|
||||
"agentManager.pr.error.gh_missing.description": "Instala GitHub CLI para habilitar el estado del PR.",
|
||||
}
|
||||
|
||||
+8
-3
@@ -3,8 +3,8 @@ export const dict = {
|
||||
"agentManager.sidebar.collapse": "بستن نوار کناری",
|
||||
"agentManager.sidebar.expand": "نمایش نوار کناری",
|
||||
"agentManager.section.worktrees": "WORKTREES",
|
||||
"agentManager.section.sessions": "SESSIONS",
|
||||
"agentManager.projects": "PROJECTS",
|
||||
"agentManager.section.sessions": "جلسات",
|
||||
"agentManager.projects": "پروژهها",
|
||||
"agentManager.project.add": "افزودن پروژه",
|
||||
"agentManager.project.remove": "حذف از Agent Manager",
|
||||
"agentManager.project.trust": "اعتماد",
|
||||
@@ -207,7 +207,7 @@ export const dict = {
|
||||
"agentManager.review.imageUnavailable": "پیشنمایش تصویر برای این نمونه جلسه در دسترس نیست.",
|
||||
"agentManager.review.endOfLongDiff": "به انتها رسیدید!",
|
||||
|
||||
"agentManager.import.pullRequest": "Pull Request",
|
||||
"agentManager.import.pullRequest": "درخواست ادغام",
|
||||
"agentManager.import.pastePrUrl": "URL درخواست PR را وارد کنید...",
|
||||
"agentManager.import.open": "باز کردن",
|
||||
"agentManager.import.branches": "شاخهها",
|
||||
@@ -218,4 +218,9 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "هیچ شاخهای یافت نشد.",
|
||||
"agentManager.import.noBranchesHint": "یک URL درخواست ادغام را در بالا جایگذاری کنید یا یک worktree جدید بسازید.",
|
||||
"agentManager.import.failed": "وارد کردن ناموفق بود",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "احراز هویت GitHub لازم است",
|
||||
"agentManager.pr.error.gh_auth.description": "برای بازیابی وضعیت PR، دستور 'gh auth login' را در ترمینال اجرا کنید.",
|
||||
"agentManager.pr.error.gh_missing.title": "GitHub CLI نصب نشده است",
|
||||
"agentManager.pr.error.gh_missing.description": "برای فعال کردن وضعیت PR، GitHub CLI را نصب کنید.",
|
||||
}
|
||||
|
||||
+10
-4
@@ -14,7 +14,7 @@ export const dict = {
|
||||
"agentManager.worktree.new": "Nouveau Worktree",
|
||||
"agentManager.worktree.setupScript": "Script de configuration du Worktree",
|
||||
"agentManager.worktree.delete": "Supprimer le Worktree",
|
||||
"agentManager.worktree.confirmDelete": "Delete?",
|
||||
"agentManager.worktree.confirmDelete": "Supprimer ?",
|
||||
"agentManager.worktree.stale": "Obsolète",
|
||||
"agentManager.worktree.staleTooltip": "Absent du disque ou n'est plus suivi par git worktree",
|
||||
"agentManager.worktree.removeStale": "Supprimer le Worktree obsolète",
|
||||
@@ -60,7 +60,7 @@ export const dict = {
|
||||
"agentManager.sidebarSearch.label": "Rechercher des Worktrees et des sessions",
|
||||
"agentManager.sidebarSearch.scope":
|
||||
"Recherche dans l'espace de travail local, les sessions locales, les Worktrees et leurs sessions",
|
||||
"agentManager.sidebarSearch.contexts": "LOCAL & WORKTREES",
|
||||
"agentManager.sidebarSearch.contexts": "LOCAL ET WORKTREES",
|
||||
|
||||
"agentManager.terminal.new": "Nouvel onglet de terminal",
|
||||
"agentManager.terminal.ended": "terminal terminé — fermez l'onglet pour ignorer",
|
||||
@@ -107,8 +107,8 @@ export const dict = {
|
||||
"agentManager.run.options": "Options d'exécution",
|
||||
"agentManager.run.configure": "Configurer le script d'exécution",
|
||||
"agentManager.shortcuts.openAgentManager": "Ouvrir Agent Manager",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Mode d'agent suivant",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Mode d'agent précédent",
|
||||
"agentManager.shortcuts.showShortcuts": "Afficher les raccourcis clavier",
|
||||
"agentManager.dialog.removeStaleWorktree.title": "Supprimer le Worktree obsolète",
|
||||
"agentManager.dialog.removeStaleWorktree.messagePre": "Supprimer le Worktree obsolète ",
|
||||
@@ -216,4 +216,10 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "Aucune branche trouvée.",
|
||||
"agentManager.import.noBranchesHint": "Collez une URL de PR ci-dessus ou créez un nouveau Worktree.",
|
||||
"agentManager.import.failed": "Échec de l'importation",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "Authentification GitHub requise",
|
||||
"agentManager.pr.error.gh_auth.description":
|
||||
"Exécutez 'gh auth login' dans votre terminal pour restaurer le statut de la PR.",
|
||||
"agentManager.pr.error.gh_missing.title": "GitHub CLI non installé",
|
||||
"agentManager.pr.error.gh_missing.description": "Installez GitHub CLI pour activer le statut de la PR.",
|
||||
}
|
||||
|
||||
+7
-1
@@ -36,7 +36,7 @@ export const dict = {
|
||||
"agentManager.section.moveUp": "Sposta su",
|
||||
"agentManager.section.moveDown": "Sposta giù",
|
||||
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.branch": "Ramo",
|
||||
"agentManager.hoverCard.worktree": "Worktree",
|
||||
"agentManager.hoverCard.base": "Base",
|
||||
"agentManager.hoverCard.sessions": "Sessioni",
|
||||
@@ -222,4 +222,10 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "Nessun branch trovato.",
|
||||
"agentManager.import.noBranchesHint": "Incolla un URL PR sopra o crea un nuovo worktree.",
|
||||
"agentManager.import.failed": "Importazione non riuscita",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "Autenticazione GitHub richiesta",
|
||||
"agentManager.pr.error.gh_auth.description":
|
||||
"Esegui 'gh auth login' nel terminale per ripristinare lo stato della PR.",
|
||||
"agentManager.pr.error.gh_missing.title": "GitHub CLI non installato",
|
||||
"agentManager.pr.error.gh_missing.description": "Installa GitHub CLI per abilitare lo stato della PR.",
|
||||
}
|
||||
|
||||
+11
-4
@@ -14,7 +14,7 @@ export const dict = {
|
||||
"agentManager.worktree.new": "新しいWorktree",
|
||||
"agentManager.worktree.setupScript": "Worktreeセットアップスクリプト",
|
||||
"agentManager.worktree.delete": "Worktreeを削除",
|
||||
"agentManager.worktree.confirmDelete": "Delete?",
|
||||
"agentManager.worktree.confirmDelete": "削除しますか?",
|
||||
"agentManager.worktree.stale": "無効",
|
||||
"agentManager.worktree.staleTooltip": "ディスク上に存在しないか、git worktree で追跡されていません",
|
||||
"agentManager.worktree.removeStale": "無効な Worktree を削除",
|
||||
@@ -107,8 +107,8 @@ export const dict = {
|
||||
"agentManager.run.options": "実行オプション",
|
||||
"agentManager.run.configure": "実行スクリプトを設定",
|
||||
"agentManager.shortcuts.openAgentManager": "Agent Managerを開く",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
|
||||
"agentManager.shortcuts.cycleAgentMode": "次のエージェントモード",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "前のエージェントモード",
|
||||
"agentManager.shortcuts.showShortcuts": "キーボードショートカットを表示",
|
||||
"agentManager.dialog.removeStaleWorktree.title": "無効な Worktree を削除",
|
||||
"agentManager.dialog.removeStaleWorktree.messagePre": "無効な Worktree を削除 ",
|
||||
@@ -204,7 +204,7 @@ export const dict = {
|
||||
"agentManager.review.imageUnavailable": "このセッションスナップショットでは画像プレビューを利用できません。",
|
||||
"agentManager.review.endOfLongDiff": "最後まで到達しました!",
|
||||
|
||||
"agentManager.import.pullRequest": "Pull Request",
|
||||
"agentManager.import.pullRequest": "プルリクエスト",
|
||||
"agentManager.import.pastePrUrl": "PR URLを貼り付け...",
|
||||
"agentManager.import.open": "開く",
|
||||
"agentManager.import.branches": "ブランチ",
|
||||
@@ -215,4 +215,11 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "ブランチが見つかりません。",
|
||||
"agentManager.import.noBranchesHint": "上にPR URLを貼り付けるか、新しいWorktreeを作成してください。",
|
||||
"agentManager.import.failed": "インポートに失敗しました",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "GitHub認証が必要です",
|
||||
"agentManager.pr.error.gh_auth.description":
|
||||
"PRステータスを復元するには、ターミナルで 'gh auth login' を実行してください。",
|
||||
"agentManager.pr.error.gh_missing.title": "GitHub CLIがインストールされていません",
|
||||
"agentManager.pr.error.gh_missing.description":
|
||||
"PRステータスを有効にするには、GitHub CLIをインストールしてください。",
|
||||
}
|
||||
|
||||
+9
-4
@@ -14,7 +14,7 @@ export const dict = {
|
||||
"agentManager.worktree.new": "새 Worktree",
|
||||
"agentManager.worktree.setupScript": "Worktree 설정 스크립트",
|
||||
"agentManager.worktree.delete": "Worktree 삭제",
|
||||
"agentManager.worktree.confirmDelete": "Delete?",
|
||||
"agentManager.worktree.confirmDelete": "삭제하시겠습니까?",
|
||||
"agentManager.worktree.stale": "오래됨",
|
||||
"agentManager.worktree.staleTooltip": "디스크에 없거나 더 이상 git worktree에서 추적되지 않습니다",
|
||||
"agentManager.worktree.removeStale": "오래된 Worktree 제거",
|
||||
@@ -105,8 +105,8 @@ export const dict = {
|
||||
"agentManager.run.options": "실행 옵션",
|
||||
"agentManager.run.configure": "실행 스크립트 구성",
|
||||
"agentManager.shortcuts.openAgentManager": "Agent Manager 열기",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
|
||||
"agentManager.shortcuts.cycleAgentMode": "다음 에이전트 모드",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "이전 에이전트 모드",
|
||||
"agentManager.shortcuts.showShortcuts": "키보드 단축키 표시",
|
||||
"agentManager.dialog.removeStaleWorktree.title": "오래된 Worktree 제거",
|
||||
"agentManager.dialog.removeStaleWorktree.messagePre": "오래된 Worktree 제거 ",
|
||||
@@ -202,7 +202,7 @@ export const dict = {
|
||||
"agentManager.review.imageUnavailable": "이 세션 스냅샷에서는 이미지 미리보기를 사용할 수 없습니다.",
|
||||
"agentManager.review.endOfLongDiff": "끝까지 도달했습니다!",
|
||||
|
||||
"agentManager.import.pullRequest": "Pull Request",
|
||||
"agentManager.import.pullRequest": "풀 리퀘스트",
|
||||
"agentManager.import.pastePrUrl": "PR URL 붙여넣기...",
|
||||
"agentManager.import.open": "열기",
|
||||
"agentManager.import.branches": "브랜치",
|
||||
@@ -213,4 +213,9 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "브랜치를 찾을 수 없습니다.",
|
||||
"agentManager.import.noBranchesHint": "위에 PR URL을 붙여넣거나 새 Worktree를 만드세요.",
|
||||
"agentManager.import.failed": "가져오기 실패",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "GitHub 인증 필요",
|
||||
"agentManager.pr.error.gh_auth.description": "PR 상태를 복원하려면 터미널에서 'gh auth login'을 실행하세요.",
|
||||
"agentManager.pr.error.gh_missing.title": "GitHub CLI가 설치되지 않음",
|
||||
"agentManager.pr.error.gh_missing.description": "PR 상태를 활성화하려면 GitHub CLI를 설치하세요.",
|
||||
}
|
||||
|
||||
+7
-2
@@ -138,7 +138,7 @@ export const dict = {
|
||||
"agentManager.dialog.baseBranch": "Basis branch",
|
||||
"agentManager.dialog.searchBranches": "Zoeken naar branches...",
|
||||
"agentManager.dialog.branchBadge.default": "standaard",
|
||||
"agentManager.dialog.branchBadge.remote": "remote",
|
||||
"agentManager.dialog.branchBadge.remote": "extern",
|
||||
"agentManager.dialog.versions": "Versies",
|
||||
"agentManager.dialog.versionHint": "{{count}} worktrees zullen parallel draaien",
|
||||
"agentManager.dialog.compareModels": "Modellen vergelijken",
|
||||
@@ -209,7 +209,7 @@ export const dict = {
|
||||
"agentManager.review.imageUnavailable": "Afbeeldingsvoorbeeld is niet beschikbaar voor deze sessiesnapshot.",
|
||||
"agentManager.review.endOfLongDiff": "Je hebt het einde bereikt!",
|
||||
|
||||
"agentManager.import.pullRequest": "Pull Request",
|
||||
"agentManager.import.pullRequest": "Pull request",
|
||||
"agentManager.import.pastePrUrl": "Plak PR URL...",
|
||||
"agentManager.import.open": "Openen",
|
||||
"agentManager.import.branches": "Branches",
|
||||
@@ -220,4 +220,9 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "Geen branches gevonden.",
|
||||
"agentManager.import.noBranchesHint": "Plak hierboven een PR URL of maak een nieuwe worktree.",
|
||||
"agentManager.import.failed": "Importeren mislukt",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "GitHub-authenticatie vereist",
|
||||
"agentManager.pr.error.gh_auth.description": "Voer 'gh auth login' uit in je terminal om de PR-status te herstellen.",
|
||||
"agentManager.pr.error.gh_missing.title": "GitHub CLI niet geïnstalleerd",
|
||||
"agentManager.pr.error.gh_missing.description": "Installeer de GitHub CLI om de PR-status in te schakelen.",
|
||||
}
|
||||
|
||||
+10
-5
@@ -14,7 +14,7 @@ export const dict = {
|
||||
"agentManager.worktree.new": "Nytt Worktree",
|
||||
"agentManager.worktree.setupScript": "Worktree-oppsettskript",
|
||||
"agentManager.worktree.delete": "Slett Worktree",
|
||||
"agentManager.worktree.confirmDelete": "Delete?",
|
||||
"agentManager.worktree.confirmDelete": "Slette?",
|
||||
"agentManager.worktree.stale": "Utdatert",
|
||||
"agentManager.worktree.staleTooltip": "Mangler på disken eller spores ikke lenger av git worktree",
|
||||
"agentManager.worktree.removeStale": "Fjern utdatert Worktree",
|
||||
@@ -105,8 +105,8 @@ export const dict = {
|
||||
"agentManager.run.options": "Kjøringsalternativer",
|
||||
"agentManager.run.configure": "Konfigurer kjøreskript",
|
||||
"agentManager.shortcuts.openAgentManager": "Åpne Agent Manager",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Neste agentmodus",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Forrige agentmodus",
|
||||
"agentManager.shortcuts.showShortcuts": "Vis tastatursnarveier",
|
||||
"agentManager.dialog.removeStaleWorktree.title": "Fjern utdatert Worktree",
|
||||
"agentManager.dialog.removeStaleWorktree.messagePre": "Fjern utdatert Worktree ",
|
||||
@@ -202,10 +202,10 @@ export const dict = {
|
||||
"Forhåndsvisning av bildet er ikke tilgjengelig for dette øyeblikksbildet av økten.",
|
||||
"agentManager.review.endOfLongDiff": "Du nådde slutten!",
|
||||
|
||||
"agentManager.import.pullRequest": "Pull Request",
|
||||
"agentManager.import.pullRequest": "Pull request",
|
||||
"agentManager.import.pastePrUrl": "Lim inn PR URL...",
|
||||
"agentManager.import.open": "Åpne",
|
||||
"agentManager.import.branches": "Branches",
|
||||
"agentManager.import.branches": "Grener",
|
||||
"agentManager.import.selectBranch": "Velg branch...",
|
||||
"agentManager.import.loading": "Laster...",
|
||||
"agentManager.import.loadingBranches": "Laster branches...",
|
||||
@@ -213,4 +213,9 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "Ingen branches funnet.",
|
||||
"agentManager.import.noBranchesHint": "Lim inn en PR URL ovenfor eller opprett et nytt Worktree.",
|
||||
"agentManager.import.failed": "Import mislyktes",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "GitHub-autentisering kreves",
|
||||
"agentManager.pr.error.gh_auth.description": "Kjør 'gh auth login' i terminalen for å gjenopprette PR-status.",
|
||||
"agentManager.pr.error.gh_missing.title": "GitHub CLI er ikke installert",
|
||||
"agentManager.pr.error.gh_missing.description": "Installer GitHub CLI for å aktivere PR-status.",
|
||||
}
|
||||
|
||||
+8
-3
@@ -14,7 +14,7 @@ export const dict = {
|
||||
"agentManager.worktree.new": "Nowy Worktree",
|
||||
"agentManager.worktree.setupScript": "Skrypt konfiguracji Worktree",
|
||||
"agentManager.worktree.delete": "Usuń Worktree",
|
||||
"agentManager.worktree.confirmDelete": "Delete?",
|
||||
"agentManager.worktree.confirmDelete": "Usunąć?",
|
||||
"agentManager.worktree.stale": "Nieaktualny",
|
||||
"agentManager.worktree.staleTooltip": "Brak na dysku lub nie jest już śledzony przez git worktree",
|
||||
"agentManager.worktree.removeStale": "Usuń nieaktualny Worktree",
|
||||
@@ -107,8 +107,8 @@ export const dict = {
|
||||
"agentManager.run.options": "Opcje uruchamiania",
|
||||
"agentManager.run.configure": "Konfiguruj skrypt uruchamiania",
|
||||
"agentManager.shortcuts.openAgentManager": "Otwórz Agent Manager",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Następny tryb agenta",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Poprzedni tryb agenta",
|
||||
"agentManager.shortcuts.showShortcuts": "Pokaż skróty klawiszowe",
|
||||
"agentManager.dialog.removeStaleWorktree.title": "Usuń nieaktualny Worktree",
|
||||
"agentManager.dialog.removeStaleWorktree.messagePre": "Usunąć nieaktualny Worktree ",
|
||||
@@ -214,4 +214,9 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "Nie znaleziono branchy.",
|
||||
"agentManager.import.noBranchesHint": "Wklej URL PR powyżej lub utwórz nowy Worktree.",
|
||||
"agentManager.import.failed": "Import nie powiódł się",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "Wymagana autoryzacja GitHub",
|
||||
"agentManager.pr.error.gh_auth.description": "Uruchom 'gh auth login' w terminalu, aby przywrócić status PR.",
|
||||
"agentManager.pr.error.gh_missing.title": "GitHub CLI nie jest zainstalowane",
|
||||
"agentManager.pr.error.gh_missing.description": "Zainstaluj GitHub CLI, aby włączyć status PR.",
|
||||
}
|
||||
|
||||
+8
-3
@@ -14,7 +14,7 @@ export const dict = {
|
||||
"agentManager.worktree.new": "Новый Worktree",
|
||||
"agentManager.worktree.setupScript": "Скрипт настройки Worktree",
|
||||
"agentManager.worktree.delete": "Удалить Worktree",
|
||||
"agentManager.worktree.confirmDelete": "Delete?",
|
||||
"agentManager.worktree.confirmDelete": "Удалить?",
|
||||
"agentManager.worktree.stale": "Устаревший",
|
||||
"agentManager.worktree.staleTooltip": "Отсутствует на диске или больше не отслеживается git worktree",
|
||||
"agentManager.worktree.removeStale": "Удалить устаревший Worktree",
|
||||
@@ -107,8 +107,8 @@ export const dict = {
|
||||
"agentManager.run.options": "Параметры запуска",
|
||||
"agentManager.run.configure": "Настроить скрипт запуска",
|
||||
"agentManager.shortcuts.openAgentManager": "Открыть Agent Manager",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Следующий режим агента",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Предыдущий режим агента",
|
||||
"agentManager.shortcuts.showShortcuts": "Показать сочетания клавиш",
|
||||
"agentManager.dialog.removeStaleWorktree.title": "Удалить устаревший Worktree",
|
||||
"agentManager.dialog.removeStaleWorktree.messagePre": "Удалить устаревший Worktree ",
|
||||
@@ -215,4 +215,9 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "Ветки не найдены.",
|
||||
"agentManager.import.noBranchesHint": "Вставьте URL PR выше или создайте новый Worktree.",
|
||||
"agentManager.import.failed": "Ошибка импорта",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "Требуется аутентификация GitHub",
|
||||
"agentManager.pr.error.gh_auth.description": "Выполните 'gh auth login' в терминале, чтобы восстановить статус PR.",
|
||||
"agentManager.pr.error.gh_missing.title": "GitHub CLI не установлен",
|
||||
"agentManager.pr.error.gh_missing.description": "Установите GitHub CLI, чтобы включить статус PR.",
|
||||
}
|
||||
|
||||
+10
-5
@@ -14,7 +14,7 @@ export const dict = {
|
||||
"agentManager.worktree.new": "Worktree ใหม่",
|
||||
"agentManager.worktree.setupScript": "สคริปต์ตั้งค่า Worktree",
|
||||
"agentManager.worktree.delete": "ลบ Worktree",
|
||||
"agentManager.worktree.confirmDelete": "Delete?",
|
||||
"agentManager.worktree.confirmDelete": "ลบ?",
|
||||
"agentManager.worktree.stale": "ล้าสมัย",
|
||||
"agentManager.worktree.staleTooltip": "ไม่พบบนดิสก์หรือไม่ได้ถูกติดตามโดย git worktree อีกต่อไป",
|
||||
"agentManager.worktree.removeStale": "ลบ Worktree ที่ล้าสมัย",
|
||||
@@ -102,8 +102,8 @@ export const dict = {
|
||||
"agentManager.run.options": "ตัวเลือกการเรียกใช้",
|
||||
"agentManager.run.configure": "กำหนดค่าสคริปต์การเรียกใช้",
|
||||
"agentManager.shortcuts.openAgentManager": "เปิด Agent Manager",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
|
||||
"agentManager.shortcuts.cycleAgentMode": "โหมดเอเจนต์ถัดไป",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "โหมดเอเจนต์ก่อนหน้า",
|
||||
"agentManager.shortcuts.showShortcuts": "แสดงปุ่มลัดแป้นพิมพ์",
|
||||
"agentManager.dialog.removeStaleWorktree.title": "ลบ Worktree ที่ล้าสมัย",
|
||||
"agentManager.dialog.removeStaleWorktree.messagePre": "ลบ Worktree ที่ล้าสมัย ",
|
||||
@@ -198,10 +198,10 @@ export const dict = {
|
||||
"agentManager.review.imageUnavailable": "ไม่สามารถแสดงตัวอย่างรูปภาพสำหรับสแนปช็อตของเซสชันนี้ได้",
|
||||
"agentManager.review.endOfLongDiff": "คุณมาถึงท้ายสุดแล้ว!",
|
||||
|
||||
"agentManager.import.pullRequest": "Pull Request",
|
||||
"agentManager.import.pullRequest": "คำขอรวมโค้ด",
|
||||
"agentManager.import.pastePrUrl": "วาง URL ของ PR...",
|
||||
"agentManager.import.open": "เปิด",
|
||||
"agentManager.import.branches": "Branches",
|
||||
"agentManager.import.branches": "สาขา",
|
||||
"agentManager.import.selectBranch": "เลือก Branch...",
|
||||
"agentManager.import.loading": "กำลังโหลด...",
|
||||
"agentManager.import.loadingBranches": "กำลังโหลด Branches...",
|
||||
@@ -209,4 +209,9 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "ไม่พบ Branch",
|
||||
"agentManager.import.noBranchesHint": "วาง URL ของ PR ด้านบนหรือสร้าง Worktree ใหม่",
|
||||
"agentManager.import.failed": "นำเข้าล้มเหลว",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "ต้องยืนยันตัวตน GitHub",
|
||||
"agentManager.pr.error.gh_auth.description": "รันคำสั่ง 'gh auth login' ในเทอร์มินัลเพื่อกู้คืนสถานะ PR",
|
||||
"agentManager.pr.error.gh_missing.title": "ไม่ได้ติดตั้ง GitHub CLI",
|
||||
"agentManager.pr.error.gh_missing.description": "ติดตั้ง GitHub CLI เพื่อเปิดใช้งานสถานะ PR",
|
||||
}
|
||||
|
||||
+7
-1
@@ -211,7 +211,7 @@ export const dict = {
|
||||
"agentManager.review.imageUnavailable": "Bu oturum anlık görüntüsü için görsel önizlemesi kullanılamıyor.",
|
||||
"agentManager.review.endOfLongDiff": "Sonuna geldiniz!",
|
||||
|
||||
"agentManager.import.pullRequest": "Pull Request",
|
||||
"agentManager.import.pullRequest": "Çekme İsteği",
|
||||
"agentManager.import.pastePrUrl": "PR URL'sini yapıştırın...",
|
||||
"agentManager.import.open": "Aç",
|
||||
"agentManager.import.branches": "Dallar",
|
||||
@@ -222,4 +222,10 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "Hiçbir dal bulunamadı.",
|
||||
"agentManager.import.noBranchesHint": "Yukarıya bir PR URL'si yapıştırın veya yeni bir worktree oluşturun.",
|
||||
"agentManager.import.failed": "İçe aktarma başarısız oldu",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "GitHub kimlik doğrulaması gerekli",
|
||||
"agentManager.pr.error.gh_auth.description":
|
||||
"PR durumunu geri yüklemek için terminalinizde 'gh auth login' komutunu çalıştırın.",
|
||||
"agentManager.pr.error.gh_missing.title": "GitHub CLI yüklü değil",
|
||||
"agentManager.pr.error.gh_missing.description": "PR durumunu etkinleştirmek için GitHub CLI'yi yükleyin.",
|
||||
}
|
||||
|
||||
+6
-1
@@ -212,7 +212,7 @@ export const dict = {
|
||||
"agentManager.review.imageUnavailable": "Попередній перегляд зображення недоступний для цього знімка сеансу.",
|
||||
"agentManager.review.endOfLongDiff": "Ви дійшли до кінця!",
|
||||
|
||||
"agentManager.import.pullRequest": "Pull Request",
|
||||
"agentManager.import.pullRequest": "Пул-реквест",
|
||||
"agentManager.import.pastePrUrl": "Вставте URL PR...",
|
||||
"agentManager.import.open": "Відкрити",
|
||||
"agentManager.import.branches": "Гілки",
|
||||
@@ -223,4 +223,9 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "Гілок не знайдено.",
|
||||
"agentManager.import.noBranchesHint": "Вставте URL PR вище або створіть нове робоче дерево.",
|
||||
"agentManager.import.failed": "Імпортування не вдалося",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "Потрібна автентифікація GitHub",
|
||||
"agentManager.pr.error.gh_auth.description": "Виконайте 'gh auth login' у терміналі, щоб відновити статус PR.",
|
||||
"agentManager.pr.error.gh_missing.title": "GitHub CLI не встановлено",
|
||||
"agentManager.pr.error.gh_missing.description": "Встановіть GitHub CLI, щоб увімкнути статус PR.",
|
||||
}
|
||||
|
||||
+9
-4
@@ -14,7 +14,7 @@ export const dict = {
|
||||
"agentManager.worktree.new": "新建 Worktree",
|
||||
"agentManager.worktree.setupScript": "Worktree 设置脚本",
|
||||
"agentManager.worktree.delete": "删除 Worktree",
|
||||
"agentManager.worktree.confirmDelete": "Delete?",
|
||||
"agentManager.worktree.confirmDelete": "删除?",
|
||||
"agentManager.worktree.stale": "已失效",
|
||||
"agentManager.worktree.staleTooltip": "在磁盘上缺失,或不再由 git worktree 跟踪",
|
||||
"agentManager.worktree.removeStale": "移除失效 Worktree",
|
||||
@@ -102,8 +102,8 @@ export const dict = {
|
||||
"agentManager.run.options": "运行选项",
|
||||
"agentManager.run.configure": "配置运行脚本",
|
||||
"agentManager.shortcuts.openAgentManager": "打开 Agent Manager",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
|
||||
"agentManager.shortcuts.cycleAgentMode": "下一个代理模式",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "上一个代理模式",
|
||||
"agentManager.shortcuts.showShortcuts": "显示键盘快捷键",
|
||||
"agentManager.dialog.removeStaleWorktree.title": "移除失效 Worktree",
|
||||
"agentManager.dialog.removeStaleWorktree.messagePre": "移除失效 Worktree ",
|
||||
@@ -196,7 +196,7 @@ export const dict = {
|
||||
"agentManager.review.imageUnavailable": "此会话快照无法提供图片预览。",
|
||||
"agentManager.review.endOfLongDiff": "你已经到末尾了!",
|
||||
|
||||
"agentManager.import.pullRequest": "Pull Request",
|
||||
"agentManager.import.pullRequest": "拉取请求",
|
||||
"agentManager.import.pastePrUrl": "粘贴 PR URL...",
|
||||
"agentManager.import.open": "打开",
|
||||
"agentManager.import.branches": "分支",
|
||||
@@ -207,4 +207,9 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "未找到分支。",
|
||||
"agentManager.import.noBranchesHint": "在上方粘贴 PR URL 或创建新的 Worktree。",
|
||||
"agentManager.import.failed": "导入失败",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "需要 GitHub 身份验证",
|
||||
"agentManager.pr.error.gh_auth.description": "在终端中运行 'gh auth login' 以恢复 PR 状态。",
|
||||
"agentManager.pr.error.gh_missing.title": "未安装 GitHub CLI",
|
||||
"agentManager.pr.error.gh_missing.description": "安装 GitHub CLI 以启用 PR 状态。",
|
||||
}
|
||||
|
||||
+9
-4
@@ -14,7 +14,7 @@ export const dict = {
|
||||
"agentManager.worktree.new": "新建 Worktree",
|
||||
"agentManager.worktree.setupScript": "Worktree 設定指令碼",
|
||||
"agentManager.worktree.delete": "刪除 Worktree",
|
||||
"agentManager.worktree.confirmDelete": "Delete?",
|
||||
"agentManager.worktree.confirmDelete": "刪除?",
|
||||
"agentManager.worktree.stale": "已失效",
|
||||
"agentManager.worktree.staleTooltip": "磁碟上不存在,或不再由 git worktree 追蹤",
|
||||
"agentManager.worktree.removeStale": "移除失效 Worktree",
|
||||
@@ -102,8 +102,8 @@ export const dict = {
|
||||
"agentManager.run.options": "執行選項",
|
||||
"agentManager.run.configure": "設定執行指令碼",
|
||||
"agentManager.shortcuts.openAgentManager": "開啟 Agent Manager",
|
||||
"agentManager.shortcuts.cycleAgentMode": "Next agent mode",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "Previous agent mode",
|
||||
"agentManager.shortcuts.cycleAgentMode": "下一個代理模式",
|
||||
"agentManager.shortcuts.cyclePreviousAgentMode": "上一個代理模式",
|
||||
"agentManager.shortcuts.showShortcuts": "顯示鍵盤快捷鍵",
|
||||
"agentManager.dialog.removeStaleWorktree.title": "移除失效 Worktree",
|
||||
"agentManager.dialog.removeStaleWorktree.messagePre": "移除失效 Worktree ",
|
||||
@@ -196,7 +196,7 @@ export const dict = {
|
||||
"agentManager.review.imageUnavailable": "此工作階段快照無法預覽圖片。",
|
||||
"agentManager.review.endOfLongDiff": "你已經到最後了!",
|
||||
|
||||
"agentManager.import.pullRequest": "Pull Request",
|
||||
"agentManager.import.pullRequest": "提取請求",
|
||||
"agentManager.import.pastePrUrl": "貼上 PR URL...",
|
||||
"agentManager.import.open": "開啟",
|
||||
"agentManager.import.branches": "分支",
|
||||
@@ -207,4 +207,9 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "找不到分支。",
|
||||
"agentManager.import.noBranchesHint": "在上方貼上 PR URL 或建立新的 Worktree。",
|
||||
"agentManager.import.failed": "匯入失敗",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "需要 GitHub 驗證",
|
||||
"agentManager.pr.error.gh_auth.description": "在終端機中執行 'gh auth login' 以還原 PR 狀態。",
|
||||
"agentManager.pr.error.gh_missing.title": "未安裝 GitHub CLI",
|
||||
"agentManager.pr.error.gh_missing.description": "安裝 GitHub CLI 以啟用 PR 狀態。",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
import { createSignal } from "solid-js"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
|
||||
export function CopyButton(props: { text: string; label?: string; class?: string }) {
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(props.text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}
|
||||
return (
|
||||
<IconButton
|
||||
icon={copied() ? "check" : "copy"}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={props.label ?? "Copy"}
|
||||
class={props.class}
|
||||
onClick={copy}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
import { Show } from "solid-js"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import type { PRStatus } from "../../src/types/messages"
|
||||
import { prBadgeIndicator, prChecksRunning } from "../WorktreeItem"
|
||||
|
||||
const INDICATOR_ICON: Record<string, string> = {
|
||||
failure: "circle-x-outline",
|
||||
changes: "warning",
|
||||
approved: "circle-check",
|
||||
}
|
||||
|
||||
export function PRBadge(props: { pr: PRStatus }) {
|
||||
const indicator = () => prBadgeIndicator(props.pr)
|
||||
return (
|
||||
<span
|
||||
class="am-pr-panel-badge am-pr-row"
|
||||
classList={{
|
||||
"am-pr-accent-draft": props.pr.state === "draft",
|
||||
"am-pr-accent-merged": props.pr.state === "merged",
|
||||
"am-pr-accent-closed": props.pr.state === "closed",
|
||||
"am-pr-accent-pending": props.pr.state === "open" && props.pr.checks.status === "pending",
|
||||
"am-pr-accent-open": props.pr.state === "open" && props.pr.checks.status !== "pending",
|
||||
"am-pr-badge-pending": prChecksRunning(props.pr),
|
||||
}}
|
||||
>
|
||||
<Icon name="pull-request" size="small" />
|
||||
<Show when={indicator() !== "none"}>
|
||||
<Icon name={INDICATOR_ICON[indicator()]} size="small" class="am-pr-badge-status" data-status={indicator()} />
|
||||
</Show>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
import { For, Show, createSignal } from "solid-js"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import type { PRStatus } from "../../src/types/messages"
|
||||
import type { PRCheck, CheckStatus } from "./pr-types"
|
||||
import { SectionHeading } from "./SectionHeading"
|
||||
import { useVSCode } from "../../src/context/vscode"
|
||||
|
||||
const CHECK: Record<CheckStatus, { icon: string; label: string }> = {
|
||||
success: { icon: "circle-check", label: "Passed" },
|
||||
failure: { icon: "circle-x-outline", label: "Failed" },
|
||||
cancelled: { icon: "circle-x-outline", label: "Cancelled" },
|
||||
skipped: { icon: "circle-x-outline", label: "Skipped" },
|
||||
pending: { icon: "play", label: "Running" },
|
||||
}
|
||||
|
||||
export function PRChecks(props: { checks: PRStatus["checks"] }) {
|
||||
const vscode = useVSCode()
|
||||
const [open, setOpen] = createSignal(true)
|
||||
return (
|
||||
<>
|
||||
<div class="am-pr-panel-divider" />
|
||||
<div class="am-pr-panel-section">
|
||||
<SectionHeading
|
||||
title="Checks"
|
||||
open={open()}
|
||||
onToggle={() => setOpen((v) => !v)}
|
||||
count={`${props.checks.passed}/${props.checks.total} passed`}
|
||||
countClass={`am-pr-checks-count-${props.checks.status}`}
|
||||
/>
|
||||
<Show when={open()}>
|
||||
<div class="am-pr-panel-checks am-pr-col">
|
||||
<For each={props.checks.checks}>
|
||||
{(check: PRCheck) => (
|
||||
<div class="am-pr-panel-check-item am-pr-row" data-status={check.status}>
|
||||
<Icon name={CHECK[check.status].icon} size="small" class="am-pr-check-icon" />
|
||||
<span class="am-pr-check-name">{check.name}</span>
|
||||
<span class="am-pr-check-status">{CHECK[check.status].label}</span>
|
||||
<Show when={check.duration}>
|
||||
<span class="am-pr-check-duration">{check.duration}</span>
|
||||
</Show>
|
||||
<Show when={check.url}>
|
||||
<Tooltip value="Open in browser" placement="bottom">
|
||||
<button
|
||||
class="am-pr-check-link"
|
||||
aria-label="Open check in browser"
|
||||
onClick={() => vscode.postMessage({ type: "openExternal", url: check.url! })}
|
||||
>
|
||||
<Icon name="link" size="small" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
import { For, Show, createSignal } from "solid-js"
|
||||
import { Markdown } from "@kilocode/kilo-ui/markdown"
|
||||
import type { PRStatus } from "../../src/types/messages"
|
||||
import type { PRComment } from "./pr-types"
|
||||
import { SectionHeading } from "./SectionHeading"
|
||||
import { CopyButton } from "./CopyButton"
|
||||
|
||||
export function PRComments(props: { comments: NonNullable<PRStatus["comments"]> }) {
|
||||
const [open, setOpen] = createSignal(true)
|
||||
return (
|
||||
<>
|
||||
<div class="am-pr-panel-divider" />
|
||||
<div class="am-pr-panel-section">
|
||||
<SectionHeading
|
||||
title="Comments"
|
||||
open={open()}
|
||||
onToggle={() => setOpen((v) => !v)}
|
||||
count={props.comments.unresolved > 0 ? `${props.comments.unresolved} unresolved` : undefined}
|
||||
countClass="am-pr-panel-unresolved"
|
||||
/>
|
||||
<Show when={open()}>
|
||||
<div class="am-pr-panel-comment-list am-pr-col">
|
||||
<For each={props.comments.comments}>
|
||||
{(comment: PRComment) => (
|
||||
<div class="am-pr-panel-comment" classList={{ "am-pr-panel-comment-resolved": comment.resolved }}>
|
||||
<div class="am-pr-panel-comment-header am-pr-row">
|
||||
<span class="am-pr-panel-comment-author">{comment.author}</span>
|
||||
<Show when={comment.file}>
|
||||
<span class="am-pr-panel-comment-file">
|
||||
{comment.file}
|
||||
<Show when={comment.line}>{`:${comment.line}`}</Show>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={comment.resolved}>
|
||||
<span class="am-pr-panel-comment-resolved-badge">Resolved</span>
|
||||
</Show>
|
||||
<CopyButton text={comment.body} class="am-pr-copy-btn" />
|
||||
</div>
|
||||
<div class="am-pr-panel-comment-body">
|
||||
<Markdown text={comment.body} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
import { Show, createSignal } from "solid-js"
|
||||
import { Markdown } from "@kilocode/kilo-ui/markdown"
|
||||
import { SectionHeading } from "./SectionHeading"
|
||||
|
||||
export function PRDescription(props: { body: string }) {
|
||||
const [open, setOpen] = createSignal(true)
|
||||
return (
|
||||
<>
|
||||
<div class="am-pr-panel-divider" />
|
||||
<div class="am-pr-panel-section">
|
||||
<SectionHeading title="Description" open={open()} onToggle={() => setOpen((v) => !v)} />
|
||||
<Show when={open()}>
|
||||
<div class="am-pr-panel-description">
|
||||
<Markdown text={props.body} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
import { Show } from "solid-js"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import type { WorktreeState } from "../../src/types/messages"
|
||||
import type { PRStatus } from "../../src/types/messages"
|
||||
|
||||
const STATE_LABEL: Record<PRStatus["state"], string> = {
|
||||
open: "Open",
|
||||
draft: "Draft",
|
||||
merged: "Merged",
|
||||
closed: "Closed",
|
||||
}
|
||||
|
||||
const REVIEW_LABEL: Partial<Record<NonNullable<PRStatus["review"]>, string>> = {
|
||||
approved: "Approved",
|
||||
changes_requested: "Changes Requested",
|
||||
pending: "Review Pending",
|
||||
}
|
||||
|
||||
export function PROverview(props: { pr: PRStatus; worktree?: WorktreeState }) {
|
||||
return (
|
||||
<div class="am-pr-panel-section">
|
||||
<Show when={props.worktree}>
|
||||
{(wt) => (
|
||||
<div class="am-pr-panel-row am-pr-row">
|
||||
<span class="am-pr-panel-label">Branch</span>
|
||||
<span class="am-pr-panel-value am-pr-panel-branch am-pr-row">
|
||||
<span class="am-pr-branch-name">{wt().branch}</span>
|
||||
<Icon name="arrow-right" size="small" class="am-pr-branch-arrow" />
|
||||
<span class="am-pr-branch-name">{wt().parentBranch}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div class="am-pr-panel-row am-pr-row">
|
||||
<span class="am-pr-panel-label">Status</span>
|
||||
<span class="am-pr-panel-value" data-pr-state={props.pr.state}>
|
||||
{STATE_LABEL[props.pr.state]}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={props.pr.review}>
|
||||
{(review) => (
|
||||
<div class="am-pr-panel-row am-pr-row">
|
||||
<span class="am-pr-panel-label">Review</span>
|
||||
<span class="am-pr-panel-value">{REVIEW_LABEL[review()]}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
import { Component, Show } from "solid-js"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import type { WorktreeState } from "../../src/types/messages"
|
||||
import type { PRStatus } from "../../src/types/messages"
|
||||
import { PRBadge } from "./PRBadge"
|
||||
import { PROverview } from "./PROverview"
|
||||
import { PRReviewers } from "./PRReviewers"
|
||||
import { PRDescription } from "./PRDescription"
|
||||
import { PRChecks } from "./PRChecks"
|
||||
import { PRComments } from "./PRComments"
|
||||
import { PRSummary } from "./PRSummary"
|
||||
import "./pr-panel.css"
|
||||
|
||||
interface PRPanelProps {
|
||||
pr: PRStatus
|
||||
worktree?: WorktreeState
|
||||
onClose: () => void
|
||||
onOpenExternal: () => void
|
||||
}
|
||||
|
||||
export const PRPanel: Component<PRPanelProps> = (props) => {
|
||||
return (
|
||||
<div class="am-pr-panel am-pr-col">
|
||||
<div class="am-pr-panel-header am-pr-row">
|
||||
<div class="am-pr-panel-title-row am-pr-row">
|
||||
<PRBadge pr={props.pr} />
|
||||
<span class="am-pr-panel-title">{props.pr.title}</span>
|
||||
<span class="am-pr-panel-number">#{props.pr.number}</span>
|
||||
</div>
|
||||
<div class="am-pr-panel-actions am-pr-row">
|
||||
<Tooltip value="Open in browser" placement="bottom">
|
||||
<IconButton
|
||||
icon="link"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label="Open in browser"
|
||||
onClick={props.onOpenExternal}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip value="Close" placement="bottom">
|
||||
<IconButton icon="close" size="small" variant="ghost" label="Close PR panel" onClick={props.onClose} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="am-pr-panel-body">
|
||||
<PRSummary pr={props.pr} />
|
||||
<PROverview pr={props.pr} worktree={props.worktree} />
|
||||
<Show when={(props.pr.reviewers ?? []).length > 0}>
|
||||
<PRReviewers reviewers={props.pr.reviewers ?? []} />
|
||||
</Show>
|
||||
<Show when={props.pr.body}>{(body) => <PRDescription body={body()} />}</Show>
|
||||
<Show when={props.pr.checks.total > 0}>
|
||||
<PRChecks checks={props.pr.checks} />
|
||||
</Show>
|
||||
<Show when={props.pr.comments?.total ? props.pr.comments : undefined}>
|
||||
{(comments) => <PRComments comments={comments()} />}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
import { For, Show, createSignal } from "solid-js"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import type { PRReviewer, ReviewerState } from "./pr-types"
|
||||
import { SectionHeading } from "./SectionHeading"
|
||||
|
||||
const REVIEWER_ICON: Record<ReviewerState, string> = {
|
||||
approved: "circle-check",
|
||||
changes_requested: "refresh",
|
||||
commented: "edit",
|
||||
pending: "dash",
|
||||
}
|
||||
|
||||
const REVIEWER_LABEL: Record<ReviewerState, string> = {
|
||||
approved: "Approved",
|
||||
changes_requested: "Changes requested",
|
||||
commented: "Commented",
|
||||
pending: "Awaiting",
|
||||
}
|
||||
|
||||
export function PRReviewers(props: { reviewers: PRReviewer[] }) {
|
||||
const [open, setOpen] = createSignal(true)
|
||||
return (
|
||||
<>
|
||||
<div class="am-pr-panel-divider" />
|
||||
<div class="am-pr-panel-section">
|
||||
<SectionHeading title="Reviewers" open={open()} onToggle={() => setOpen((v) => !v)} />
|
||||
<Show when={open()}>
|
||||
<div class="am-pr-panel-reviewers am-pr-col">
|
||||
<For each={props.reviewers}>
|
||||
{(reviewer) => (
|
||||
<div class="am-pr-panel-reviewer am-pr-row" data-state={reviewer.state}>
|
||||
<Icon name={REVIEWER_ICON[reviewer.state]} size="small" class="am-pr-reviewer-icon" />
|
||||
<span class="am-pr-reviewer-login">{reviewer.login}</span>
|
||||
<span class="am-pr-reviewer-state">{REVIEWER_LABEL[reviewer.state]}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
import { Show } from "solid-js"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import type { PRStatus } from "../../src/types/messages"
|
||||
|
||||
interface PRSummaryProps {
|
||||
pr: PRStatus
|
||||
}
|
||||
|
||||
function summaryRows(pr: PRStatus): Array<{ icon: string; label: string; status: string }> {
|
||||
const rows = []
|
||||
|
||||
if (pr.checks.total > 0) {
|
||||
const { passed, total, status } = pr.checks
|
||||
rows.push({
|
||||
icon: status === "success" ? "circle-check" : status === "failure" ? "circle-x-outline" : "play",
|
||||
label: status === "success" ? "All checks passing" : `${passed}/${total} checks passed`,
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
if (pr.review) {
|
||||
const status = pr.review === "approved" ? "success" : pr.review === "changes_requested" ? "failure" : "pending"
|
||||
rows.push({
|
||||
icon: status === "success" ? "circle-check" : status === "failure" ? "circle-x-outline" : "play",
|
||||
label: status === "success" ? "Approved" : status === "failure" ? "Changes requested" : "Review pending",
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
if (pr.comments && pr.comments.unresolved > 0) {
|
||||
rows.push({
|
||||
icon: "comment",
|
||||
label: `${pr.comments.unresolved} unresolved comment${pr.comments.unresolved > 1 ? "s" : ""}`,
|
||||
status: "warning",
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
export function PRSummary(props: PRSummaryProps) {
|
||||
const rows = () => summaryRows(props.pr)
|
||||
return (
|
||||
<Show when={rows().length > 0}>
|
||||
<div class="am-pr-summary">
|
||||
<div class="am-pr-summary-header am-pr-row">
|
||||
<span class="am-pr-summary-title">PR Summary</span>
|
||||
<span class="am-pr-panel-section-count am-pr-panel-diff am-pr-row">
|
||||
<Show when={props.pr.files > 0}>
|
||||
<span class="am-stat-files">{props.pr.files}f</span>
|
||||
</Show>
|
||||
<Show when={props.pr.additions > 0}>
|
||||
<span class="am-stat-additions">+{props.pr.additions}</span>
|
||||
</Show>
|
||||
<Show when={props.pr.deletions > 0}>
|
||||
<span class="am-stat-deletions">−{props.pr.deletions}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
<div class="am-pr-summary-rows am-pr-col">
|
||||
{rows().map((row) => (
|
||||
<div class="am-pr-summary-row am-pr-row" data-status={row.status}>
|
||||
<Icon name={row.icon} size="small" class="am-pr-summary-icon" />
|
||||
<span class="am-pr-summary-label">{row.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
import { Show } from "solid-js"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
|
||||
export function SectionHeading(props: {
|
||||
title: string
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
count?: string
|
||||
countClass?: string
|
||||
}) {
|
||||
return (
|
||||
<button class="am-pr-panel-section-heading am-pr-panel-section-toggle am-pr-row" onClick={props.onToggle}>
|
||||
<span class="am-pr-panel-section-heading-left am-pr-row">
|
||||
<Icon name={props.open ? "chevron-down" : "chevron-right"} size="small" class="am-pr-section-chevron" />
|
||||
{props.title}
|
||||
</span>
|
||||
<Show when={props.count}>
|
||||
<span class={`am-pr-panel-section-count ${props.countClass ?? ""}`}>{props.count}</span>
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
/* PR Panel */
|
||||
.am-pr-panel {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.am-pr-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.am-pr-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.am-pr-panel-header {
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 8px;
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.am-pr-panel-title-row {
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.am-pr-panel-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-size: var(--kilo-font-size-10);
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: color-mix(in srgb, var(--am-pr-accent) 15%, transparent);
|
||||
color: var(--am-pr-accent);
|
||||
border: 1px solid color-mix(in srgb, var(--am-pr-accent) 40%, transparent);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.am-pr-panel-badge.am-pr-badge-pending {
|
||||
animation: am-pr-badge-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.am-pr-panel-title {
|
||||
font-size: var(--kilo-font-size-14);
|
||||
color: var(--vscode-foreground);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.am-pr-panel-number {
|
||||
font-size: var(--kilo-font-size-12);
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.am-pr-panel-actions {
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.am-pr-panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.am-pr-panel-section {
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.am-pr-panel-section-heading {
|
||||
justify-content: space-between;
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-weak);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.am-pr-panel-section-toggle {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.am-pr-panel-section-toggle:hover {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.am-pr-panel-section-heading-left {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.am-pr-section-chevron {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Branch → base row */
|
||||
.am-pr-panel-branch {
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.am-pr-branch-name {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: var(--kilo-font-size-11);
|
||||
}
|
||||
|
||||
.am-pr-branch-arrow {
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Check count color by status */
|
||||
.am-pr-checks-count-success {
|
||||
color: var(--vscode-testing-iconPassed, #34d399);
|
||||
}
|
||||
.am-pr-checks-count-failure {
|
||||
color: var(--vscode-testing-iconFailed, #f87171);
|
||||
}
|
||||
.am-pr-checks-count-pending {
|
||||
color: var(--vscode-charts-yellow, #fbbf24);
|
||||
}
|
||||
|
||||
.am-pr-panel-section-count {
|
||||
font-size: var(--kilo-font-size-11);
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
.am-pr-panel-divider {
|
||||
height: 1px;
|
||||
background: var(--vscode-panel-border);
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.am-pr-panel-row {
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 3px 0;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.am-pr-panel-label {
|
||||
font-size: var(--kilo-font-size-11);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-weaker);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.am-pr-panel-value {
|
||||
font-size: var(--kilo-font-size-12);
|
||||
color: var(--vscode-foreground);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.am-pr-panel-value[data-pr-state="open"] {
|
||||
color: var(--vscode-testing-iconPassed, #34d399);
|
||||
}
|
||||
.am-pr-panel-value[data-pr-state="merged"] {
|
||||
color: var(--vscode-charts-purple, #a78bfa);
|
||||
}
|
||||
.am-pr-panel-value[data-pr-state="closed"] {
|
||||
color: var(--vscode-testing-iconFailed, #f87171);
|
||||
}
|
||||
.am-pr-panel-value[data-pr-state="draft"] {
|
||||
color: var(--text-weaker);
|
||||
}
|
||||
|
||||
.am-pr-panel-value[data-checks="failure"] {
|
||||
color: var(--vscode-testing-iconFailed, #f87171);
|
||||
}
|
||||
.am-pr-panel-value[data-checks="pending"] {
|
||||
color: var(--vscode-charts-yellow, #fbbf24);
|
||||
}
|
||||
.am-pr-panel-value[data-checks="success"] {
|
||||
color: var(--vscode-testing-iconPassed, #34d399);
|
||||
}
|
||||
|
||||
.am-pr-panel-diff {
|
||||
gap: 6px;
|
||||
}
|
||||
.am-pr-panel-unresolved {
|
||||
color: var(--vscode-charts-yellow, #fbbf24);
|
||||
}
|
||||
|
||||
/* PR comment list */
|
||||
.am-pr-panel-comment-list {
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.am-pr-panel-comment {
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.am-pr-panel-comment-resolved {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.am-pr-panel-comment-header {
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.am-pr-panel-comment-author {
|
||||
font-size: var(--kilo-font-size-12);
|
||||
font-weight: 600;
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.am-pr-panel-comment-file {
|
||||
font-size: var(--kilo-font-size-11);
|
||||
color: var(--text-weak);
|
||||
font-family: var(--font-mono, monospace);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.am-pr-panel-comment-resolved-badge {
|
||||
font-size: var(--kilo-font-size-10);
|
||||
color: var(--text-weaker);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 3px;
|
||||
padding: 1px 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.am-pr-copy-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.am-pr-panel-comment-body [data-component="markdown"] {
|
||||
font-size: var(--kilo-font-size-12);
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.am-pr-panel-description {
|
||||
line-height: 1.5;
|
||||
padding: 2px 0 4px;
|
||||
}
|
||||
|
||||
.am-pr-panel-description [data-component="markdown"],
|
||||
.am-pr-panel-comment-body [data-component="markdown"] {
|
||||
font-size: var(--kilo-font-size-13);
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.am-pr-panel-description [data-component="markdown"] h1,
|
||||
.am-pr-panel-description [data-component="markdown"] h2,
|
||||
.am-pr-panel-description [data-component="markdown"] h3,
|
||||
.am-pr-panel-description [data-component="markdown"] h4,
|
||||
.am-pr-panel-description [data-component="markdown"] h5,
|
||||
.am-pr-panel-description [data-component="markdown"] h6 {
|
||||
font-weight: 600;
|
||||
color: var(--vscode-foreground);
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.am-pr-panel-description [data-component="markdown"] h1 {
|
||||
font-size: var(--kilo-font-size-16);
|
||||
}
|
||||
.am-pr-panel-description [data-component="markdown"] h2 {
|
||||
font-size: var(--kilo-font-size-14);
|
||||
}
|
||||
.am-pr-panel-description [data-component="markdown"] h3 {
|
||||
font-size: var(--kilo-font-size-13);
|
||||
}
|
||||
|
||||
/* PR Reviewers */
|
||||
.am-pr-panel-reviewers {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.am-pr-panel-reviewer {
|
||||
gap: 6px;
|
||||
padding: 3px 0;
|
||||
font-size: var(--kilo-font-size-12);
|
||||
}
|
||||
|
||||
.am-pr-reviewer-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.am-pr-panel-reviewer[data-state="approved"] .am-pr-reviewer-icon {
|
||||
color: var(--vscode-testing-iconPassed, #34d399);
|
||||
}
|
||||
.am-pr-panel-reviewer[data-state="changes_requested"] .am-pr-reviewer-icon {
|
||||
color: var(--vscode-charts-yellow, #fbbf24);
|
||||
}
|
||||
.am-pr-panel-reviewer[data-state="commented"] .am-pr-reviewer-icon {
|
||||
color: var(--text-weak);
|
||||
}
|
||||
.am-pr-panel-reviewer[data-state="pending"] .am-pr-reviewer-icon {
|
||||
color: var(--text-weaker);
|
||||
}
|
||||
|
||||
.am-pr-reviewer-login {
|
||||
flex: 1;
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.am-pr-reviewer-state {
|
||||
color: var(--text-weak);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.am-pr-panel-checks {
|
||||
gap: 2px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.am-pr-panel-check-item {
|
||||
gap: 6px;
|
||||
padding: 3px 0;
|
||||
font-size: var(--kilo-font-size-12);
|
||||
}
|
||||
|
||||
.am-pr-check-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.am-pr-panel-check-item[data-status="success"] .am-pr-check-icon {
|
||||
color: var(--vscode-testing-iconPassed, #34d399);
|
||||
}
|
||||
.am-pr-panel-check-item[data-status="failure"] .am-pr-check-icon {
|
||||
color: var(--vscode-testing-iconFailed, #f87171);
|
||||
}
|
||||
.am-pr-panel-check-item[data-status="cancelled"] .am-pr-check-icon {
|
||||
color: var(--text-weaker);
|
||||
}
|
||||
.am-pr-panel-check-item[data-status="skipped"] .am-pr-check-icon {
|
||||
color: var(--text-weaker);
|
||||
}
|
||||
.am-pr-panel-check-item[data-status="pending"] .am-pr-check-icon {
|
||||
color: var(--vscode-charts-yellow, #fbbf24);
|
||||
}
|
||||
|
||||
.am-pr-check-name {
|
||||
flex: 1;
|
||||
color: var(--vscode-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.am-pr-check-status {
|
||||
color: var(--text-weak);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.am-pr-check-duration {
|
||||
color: var(--text-weaker);
|
||||
font-size: var(--kilo-font-size-10);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.am-pr-check-link {
|
||||
color: var(--text-weak);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.am-pr-check-link:hover {
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
/* PR toggle button in tab bar */
|
||||
.am-pr-toggle-number {
|
||||
font-size: var(--kilo-font-size-10);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* PR Summary banner */
|
||||
.am-pr-summary {
|
||||
margin: 8px 12px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.am-pr-summary-header {
|
||||
justify-content: space-between;
|
||||
padding: 6px 6px 6px 10px;
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
background: var(--vscode-editor-background);
|
||||
}
|
||||
|
||||
.am-pr-summary-title {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
.am-pr-summary-rows {
|
||||
padding: 6px 10px;
|
||||
gap: 4px;
|
||||
background: var(--vscode-editor-background);
|
||||
}
|
||||
|
||||
.am-pr-summary-row {
|
||||
gap: 7px;
|
||||
padding: 2px 0;
|
||||
font-size: var(--kilo-font-size-12);
|
||||
}
|
||||
|
||||
.am-pr-summary-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.am-pr-summary-row[data-status="success"] .am-pr-summary-icon {
|
||||
color: var(--vscode-testing-iconPassed, #34d399);
|
||||
}
|
||||
.am-pr-summary-row[data-status="failure"] .am-pr-summary-icon {
|
||||
color: var(--vscode-testing-iconFailed, #f87171);
|
||||
}
|
||||
.am-pr-summary-row[data-status="pending"] .am-pr-summary-icon {
|
||||
color: var(--vscode-charts-yellow, #fbbf24);
|
||||
}
|
||||
.am-pr-summary-row[data-status="warning"] .am-pr-summary-icon {
|
||||
color: var(--vscode-charts-yellow, #fbbf24);
|
||||
}
|
||||
|
||||
.am-pr-summary-label {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// PR sub-types — source of truth for all PR-related types used in the PR panel.
|
||||
// PRStatus lives in src/types/messages/agent-manager.ts for broad consumption.
|
||||
|
||||
export type PRState = "open" | "draft" | "merged" | "closed"
|
||||
export type ReviewDecision = "approved" | "changes_requested" | "pending"
|
||||
export type CheckStatus = "success" | "failure" | "pending" | "skipped" | "cancelled"
|
||||
export type AggregateCheckStatus = "success" | "failure" | "pending" | "none"
|
||||
|
||||
export interface PRCheck {
|
||||
name: string
|
||||
status: CheckStatus
|
||||
url?: string
|
||||
duration?: string
|
||||
}
|
||||
|
||||
export interface PRComment {
|
||||
id: string
|
||||
author: string
|
||||
avatar?: string
|
||||
body: string
|
||||
file?: string
|
||||
line?: number
|
||||
url?: string
|
||||
resolved: boolean
|
||||
createdAt?: number
|
||||
}
|
||||
|
||||
export type ReviewerState = "approved" | "changes_requested" | "pending" | "commented"
|
||||
|
||||
export interface PRReviewer {
|
||||
login: string
|
||||
avatar?: string
|
||||
state: ReviewerState
|
||||
}
|
||||
@@ -5,6 +5,12 @@ export const SIDE_RESIZE_INTERVAL_MS = 32
|
||||
|
||||
type Frame = (callback: (time: number) => void) => number
|
||||
|
||||
export enum SidePanel {
|
||||
Diff = "diff",
|
||||
PR = "pr",
|
||||
Terminal = "terminal",
|
||||
}
|
||||
|
||||
function viewportWidth(viewport: number): number {
|
||||
return Number.isFinite(viewport) && viewport > 0 ? viewport : MIN_PANEL_WIDTH
|
||||
}
|
||||
|
||||
@@ -13,11 +13,12 @@
|
||||
import { createEffect, createSignal, type Accessor } from "solid-js"
|
||||
import type { ScriptTerminalStatus, TerminalStateControls } from "./state"
|
||||
import type { TerminalTabStateWithContext } from "./state"
|
||||
import type { SidePanelState } from "../AgentManagerApp"
|
||||
|
||||
interface AmbientSetupDeps {
|
||||
terms: TerminalStateControls
|
||||
selection: Accessor<string | null>
|
||||
sidePanel: Accessor<string | null>
|
||||
sidePanel: Accessor<SidePanelState>
|
||||
setSidePanel(panel: null): void
|
||||
}
|
||||
|
||||
|
||||
@@ -451,6 +451,18 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => {
|
||||
setErrors("models", (v) => v.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function toggleAllReasoning() {
|
||||
const all = form.models.length > 0 && form.models.every((m) => m.reasoning)
|
||||
const target = !all
|
||||
form.models.forEach((_, i) => setForm("models", i, "reasoning", target))
|
||||
}
|
||||
|
||||
function toggleAllImages() {
|
||||
const all = form.models.length > 0 && form.models.every((m) => m.supportsImages)
|
||||
const target = !all
|
||||
form.models.forEach((_, i) => setForm("models", i, "supportsImages", target))
|
||||
}
|
||||
|
||||
function addHeader() {
|
||||
setForm("headers", (v) => [...v, { key: "", value: "" }])
|
||||
setErrors("headers", (v) => [...v, {}])
|
||||
@@ -515,6 +527,7 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => {
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
size="large"
|
||||
title={
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
@@ -530,13 +543,15 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => {
|
||||
style={{
|
||||
display: "flex",
|
||||
"flex-direction": "column",
|
||||
gap: "24px",
|
||||
padding: "0 10px 12px 10px",
|
||||
gap: "20px",
|
||||
padding: "0 16px 16px 16px",
|
||||
"overflow-y": "auto",
|
||||
"max-height": "60vh",
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
"box-sizing": "border-box",
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "0 10px", display: "flex", gap: "16px", "align-items": "center" }}>
|
||||
<div style={{ display: "flex", gap: "16px", "align-items": "center" }}>
|
||||
<ProviderIcon id="synthetic" width={20} height={20} />
|
||||
<div
|
||||
style={{ "font-size": "var(--kilo-font-size-16)", "font-weight": "500", color: "var(--vscode-foreground)" }}
|
||||
@@ -545,36 +560,35 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={save}
|
||||
style={{ padding: "0 10px 24px 10px", display: "flex", "flex-direction": "column", gap: "24px" }}
|
||||
>
|
||||
<div style={{ "font-size": "var(--kilo-font-size-14)", color: "var(--text-base)" }}>
|
||||
{language.t("provider.custom.description.prefix")}
|
||||
<a
|
||||
href="https://kilo.ai/docs/ai-providers#custom-provider"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
vscode.postMessage({
|
||||
type: "openExternal",
|
||||
url: "https://kilo.ai/docs/ai-providers#custom-provider",
|
||||
})
|
||||
}}
|
||||
>
|
||||
{language.t("provider.custom.description.link")}
|
||||
</a>
|
||||
{language.t("provider.custom.description.suffix")}
|
||||
<form onSubmit={save} style={{ display: "flex", "flex-direction": "column", gap: "20px" }}>
|
||||
<div style={{ display: "flex", "flex-direction": "column", gap: "10px" }}>
|
||||
<div style={{ "font-size": "var(--kilo-font-size-14)", color: "var(--text-base)" }}>
|
||||
{language.t("provider.custom.description.prefix")}
|
||||
<a
|
||||
href="https://kilo.ai/docs/ai-providers#custom-provider"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
vscode.postMessage({
|
||||
type: "openExternal",
|
||||
url: "https://kilo.ai/docs/ai-providers#custom-provider",
|
||||
})
|
||||
}}
|
||||
>
|
||||
{language.t("provider.custom.description.link")}
|
||||
</a>
|
||||
{language.t("provider.custom.description.suffix")}
|
||||
</div>
|
||||
<Show when={editing()}>
|
||||
<div style={{ "margin-top": "8px" }}>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
vscode.postMessage(configMessage("global", language.t))
|
||||
}}
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="small"
|
||||
icon="edit"
|
||||
onClick={() => vscode.postMessage(configMessage("global", language.t))}
|
||||
>
|
||||
{language.t("provider.custom.edit.advanced")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -651,19 +665,49 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => {
|
||||
|
||||
{/* Models */}
|
||||
<div style={{ display: "flex", "flex-direction": "column", gap: "12px" }}>
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "8px" }}>
|
||||
<label
|
||||
style={{
|
||||
"font-size": "var(--kilo-font-size-12)",
|
||||
"font-weight": "500",
|
||||
color: "var(--text-weak-base)",
|
||||
}}
|
||||
>
|
||||
{language.t("provider.custom.models.label")}
|
||||
</label>
|
||||
<Show when={fetching()}>
|
||||
<Spinner style={{ width: "12px", height: "12px" }} />
|
||||
</Show>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"justify-content": "space-between",
|
||||
"align-items": "center",
|
||||
"flex-wrap": "wrap",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "8px" }}>
|
||||
<label
|
||||
style={{
|
||||
"font-size": "var(--kilo-font-size-12)",
|
||||
"font-weight": "500",
|
||||
color: "var(--text-weak-base)",
|
||||
}}
|
||||
>
|
||||
{language.t("provider.custom.models.label")}
|
||||
</label>
|
||||
<Show when={fetching()}>
|
||||
<Spinner style={{ width: "12px", height: "12px" }} />
|
||||
</Show>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "8px", "align-items": "center", "flex-wrap": "wrap" }}>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={toggleAllReasoning}
|
||||
disabled={form.models.length === 0}
|
||||
>
|
||||
{language.t("provider.custom.models.toggleReasoning")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={toggleAllImages}
|
||||
disabled={form.models.length === 0}
|
||||
>
|
||||
{language.t("provider.custom.models.toggleImages")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<For each={form.models}>
|
||||
{(m, i) => (
|
||||
|
||||
+37
-35
@@ -98,42 +98,44 @@ export function ModelCard(props: ModelCardProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reasoning toggle */}
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
gap: "8px",
|
||||
cursor: "pointer",
|
||||
"font-size": "var(--kilo-font-size-13)",
|
||||
color: "var(--vscode-foreground)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.m.reasoning}
|
||||
onChange={(e) => props.onChangeReasoning(e.currentTarget.checked)}
|
||||
/>
|
||||
{props.t("provider.custom.models.reasoning.label")}
|
||||
</label>
|
||||
{/* Reasoning and Image toggles */}
|
||||
<div style={{ display: "flex", gap: "16px", "align-items": "center", "flex-wrap": "wrap" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
gap: "8px",
|
||||
cursor: "pointer",
|
||||
"font-size": "var(--kilo-font-size-13)",
|
||||
color: "var(--vscode-foreground)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.m.reasoning}
|
||||
onChange={(e) => props.onChangeReasoning(e.currentTarget.checked)}
|
||||
/>
|
||||
{props.t("provider.custom.models.reasoning.label")}
|
||||
</label>
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
gap: "8px",
|
||||
cursor: "pointer",
|
||||
"font-size": "var(--kilo-font-size-13)",
|
||||
color: "var(--vscode-foreground)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.m.supportsImages}
|
||||
onChange={(e) => props.onChangeSupportsImages(e.currentTarget.checked)}
|
||||
/>
|
||||
{props.t("provider.custom.models.modalities.image")}
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
gap: "8px",
|
||||
cursor: "pointer",
|
||||
"font-size": "var(--kilo-font-size-13)",
|
||||
color: "var(--vscode-foreground)",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.m.supportsImages}
|
||||
onChange={(e) => props.onChangeSupportsImages(e.currentTarget.checked)}
|
||||
/>
|
||||
{props.t("provider.custom.models.modalities.image")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Show when={issue()}>
|
||||
{(error) => (
|
||||
|
||||
@@ -50,12 +50,13 @@ export const ModelPreview: Component<Props> = (props) => {
|
||||
<Show when={m()}>
|
||||
{(model) => {
|
||||
const cost = () => model().cost
|
||||
const hasPricing = () => cost() && (cost()!.input > 0 || cost()!.output > 0)
|
||||
const bench = () => model().terminalBench
|
||||
const cachedText = () => {
|
||||
if (!cost()) return ""
|
||||
if (!cost() || !hasPricing()) return ""
|
||||
return fmtCachedPrice(cost()!) ?? language.t("model.preview.value.notSupported")
|
||||
}
|
||||
const avg = () => (cost() ? avgPrice(cost()!) : undefined)
|
||||
const avg = () => (cost() && hasPricing() ? avgPrice(cost()!) : undefined)
|
||||
const freeLabel = () => language.t("model.tag.free")
|
||||
const dataLabel = () => freeDataLabel(language.t("model.tag.free"), language.t("model.tag.dataCollected"))
|
||||
const autoLabel = () => autoSummary(model())
|
||||
@@ -139,8 +140,8 @@ export const ModelPreview: Component<Props> = (props) => {
|
||||
<span class="model-preview-value">{fmtDate(model().releaseDate!)}</span>
|
||||
</Show>
|
||||
|
||||
{/* Pricing — hidden for free models */}
|
||||
<Show when={cost() && !model().isFree}>
|
||||
{/* Pricing — hidden for free models or models without fixed pricing */}
|
||||
<Show when={cost() && !model().isFree && hasPricing()}>
|
||||
<span class="model-preview-label">{language.t("model.preview.label.input")}</span>
|
||||
<span class="model-preview-value">{fmtPrice(cost()!.input)}</span>
|
||||
<span class="model-preview-label">{language.t("model.preview.label.output")}</span>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Expects `n` in $/M tokens (as stored in model.cost.input / model.cost.output).
|
||||
*/
|
||||
export function fmtPrice(n: number): string {
|
||||
if (n < 0) return "—"
|
||||
if (n === 0) return "Free"
|
||||
if (n < 0.01) return `$${n.toFixed(4)}/1M`
|
||||
return `$${n.toFixed(2)}/1M`
|
||||
|
||||
@@ -52,6 +52,8 @@ const Root: ParentComponent = (props) => (
|
||||
<MermaidDownloadBridge />
|
||||
<ServerProvider>
|
||||
<LanguageBridge>
|
||||
{/* MarkedProvider is required here for all markdown consumers in the tree,
|
||||
including PRPanel's PRDescription and PRComments components. Do not remove. */}
|
||||
<MarkedProvider>
|
||||
<DiffComponentProvider component={Diff}>
|
||||
<CodeComponentProvider component={Code}>
|
||||
|
||||
@@ -3,6 +3,27 @@ import type { Message } from "../types/messages"
|
||||
type Entry = { id: string; error?: Message["error"] }
|
||||
type Error = NonNullable<Message["error"]>
|
||||
|
||||
function sameError(a: Message, b: Message) {
|
||||
if (!a.error || !b.error || a.error.name !== b.error.name) return false
|
||||
if (a.parentID !== b.parentID) return false
|
||||
return JSON.stringify(a.error.data) === JSON.stringify(b.error.data)
|
||||
}
|
||||
|
||||
export function withoutResolvedSessionErrors(current: Message[], incoming: Message[]) {
|
||||
const events = new Set(incoming.map((msg) => msg.sessionErrorID).filter((id): id is string => !!id))
|
||||
return current.filter((msg) => {
|
||||
if (!msg.sessionErrorID) return true
|
||||
if (events.has(msg.sessionErrorID)) return false
|
||||
return !incoming.some((next) => !next.sessionErrorID && sameError(msg, next))
|
||||
})
|
||||
}
|
||||
|
||||
export function preserveSessionErrors(current: Message[], incoming: Message[]) {
|
||||
const ids = new Set(incoming.map((msg) => msg.id))
|
||||
const errors = withoutResolvedSessionErrors(current, incoming).filter((msg) => msg.sessionErrorID && !ids.has(msg.id))
|
||||
return [...incoming, ...errors]
|
||||
}
|
||||
|
||||
export function errorIDs(messages: Entry[]) {
|
||||
return messages.filter((msg) => !!msg.error).map((msg) => msg.id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Message, MessageLoadMode, Part } from "../types/messages"
|
||||
import { sameParts } from "./session-parts"
|
||||
import { withoutResolvedSessionErrors } from "./session-errors"
|
||||
|
||||
export function mergeMessages(current: Message[], incoming: Message[], mode: Exclude<MessageLoadMode, "focus">) {
|
||||
const kept = withoutResolvedSessionErrors(current, incoming)
|
||||
if (mode === "reconcile") {
|
||||
// Tail reconcile: incoming is the authoritative newest-N snapshot.
|
||||
// Local state may already hold some of those IDs and may also hold
|
||||
// newer optimistic entries created after the fetch was taken. Merge
|
||||
// by id (server wins on collision) then sort by createdAt so new
|
||||
// server messages land in the right position and optimistic tail
|
||||
// entries stay at the end.
|
||||
const byId = new Map<string, Message>()
|
||||
for (const msg of kept) byId.set(msg.id, msg)
|
||||
for (const msg of incoming) byId.set(msg.id, msg)
|
||||
return [...byId.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const source = mode === "prepend" ? [...incoming, ...kept] : incoming
|
||||
return source.filter((msg) => {
|
||||
if (seen.has(msg.id)) return false
|
||||
seen.add(msg.id)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Cheap tail check: same ids in the same order and no visible streamed-part
|
||||
// correction to apply. It skips store churn when SSE already matches the
|
||||
// snapshot, but lets reconcile heal part removals and finalized text.
|
||||
export function sameReconcileShape(
|
||||
current: Message[],
|
||||
incoming: Message[],
|
||||
getParts: (messageID: string) => Part[] | undefined,
|
||||
): boolean {
|
||||
if (current.length !== incoming.length) return false
|
||||
for (const [i, n] of incoming.entries()) {
|
||||
const c = current[i]!
|
||||
if (c.id !== n.id) return false
|
||||
if (!sameParts(getParts(c.id) ?? c.parts, n.parts)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -71,9 +71,10 @@ import { Identifier } from "../utils/id"
|
||||
import { resolveModelSelection } from "./model-selection"
|
||||
import { getAgentModel } from "./session-model-store"
|
||||
import { resolveMessagePrefs } from "./session-preferences"
|
||||
import { errorIDs } from "./session-errors"
|
||||
import { errorIDs, preserveSessionErrors, withoutResolvedSessionErrors } from "./session-errors"
|
||||
import { PartStash } from "./part-stash"
|
||||
import { mergeParts, sameParts } from "./session-parts"
|
||||
import { mergeParts } from "./session-parts"
|
||||
import { mergeMessages, sameReconcileShape } from "./session-merge"
|
||||
import { state as todoState } from "./todo-revert"
|
||||
import { sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store"
|
||||
import { createSessionVariants } from "./session-variants"
|
||||
@@ -1168,7 +1169,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
break
|
||||
|
||||
case "sessionError": {
|
||||
if (message.error?.name === "MessageAbortedError") break
|
||||
if (!message.error || message.error.name === "MessageAbortedError") break
|
||||
const sid = message.sessionID ?? currentSessionID()
|
||||
if (!sid) break
|
||||
// Find the last user message in this session to use as parentID
|
||||
@@ -1181,6 +1182,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
createdAt: new Date().toISOString(),
|
||||
parentID: parent?.id,
|
||||
error: message.error,
|
||||
sessionErrorID: message.eventID,
|
||||
}
|
||||
handleMessageCreated(errorMsg)
|
||||
break
|
||||
@@ -1373,28 +1375,6 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
setPages(sessionID, { ...(pages[sessionID] ?? emptyPageState), ...patch })
|
||||
}
|
||||
|
||||
function mergeMessages(current: Message[], incoming: Message[], mode: Exclude<MessageLoadMode, "focus">) {
|
||||
if (mode === "reconcile") {
|
||||
// Tail reconcile: incoming is the authoritative newest-N snapshot.
|
||||
// Local state may already hold some of those IDs and may also hold
|
||||
// newer optimistic entries created after the fetch was taken. Merge
|
||||
// by id (server wins on collision) then sort by createdAt so new
|
||||
// server messages land in the right position and optimistic tail
|
||||
// entries stay at the end.
|
||||
const byId = new Map<string, Message>()
|
||||
for (const msg of current) byId.set(msg.id, msg)
|
||||
for (const msg of incoming) byId.set(msg.id, msg)
|
||||
return [...byId.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const source = mode === "prepend" ? [...incoming, ...current] : incoming
|
||||
return source.filter((msg) => {
|
||||
if (seen.has(msg.id)) return false
|
||||
seen.add(msg.id)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function recoverPrefs(sessionID: string, messages: Message[], names = agentNames()) {
|
||||
const prefs = resolveMessagePrefs(messages, names)
|
||||
if (prefs.agent && !store.agentSelections[sessionID]) {
|
||||
@@ -1411,25 +1391,13 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
}
|
||||
|
||||
function withPending(sessionID: string, messages: Message[]) {
|
||||
const pending = pendingOptimistic.get(sessionID)
|
||||
if (!pending || pending.size === 0) return messages
|
||||
const ids = new Set(messages.map((msg) => msg.id))
|
||||
const current = store.messages[sessionID] ?? []
|
||||
const merged = preserveSessionErrors(current, messages)
|
||||
const pending = pendingOptimistic.get(sessionID)
|
||||
if (!pending || pending.size === 0) return merged
|
||||
const ids = new Set(merged.map((msg) => msg.id))
|
||||
const orphans = current.filter((msg) => pending.has(msg.id) && !ids.has(msg.id))
|
||||
return [...messages, ...orphans]
|
||||
}
|
||||
|
||||
// Cheap tail check: same ids in the same order and no visible streamed-part
|
||||
// correction to apply. It skips store churn when SSE already matches the
|
||||
// snapshot, but lets reconcile heal part removals and finalized text.
|
||||
function sameReconcileShape(current: Message[], incoming: Message[]): boolean {
|
||||
if (current.length !== incoming.length) return false
|
||||
for (const [i, n] of incoming.entries()) {
|
||||
const c = current[i]!
|
||||
if (c.id !== n.id) return false
|
||||
if (!sameParts(store.parts[c.id] ?? c.parts, n.parts)) return false
|
||||
}
|
||||
return true
|
||||
return [...merged, ...orphans]
|
||||
}
|
||||
|
||||
function setTools(sessionID: string, tools: ToolPart[]) {
|
||||
@@ -1480,7 +1448,10 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
// Reconcile fast-path: if the tail matches local state shape-wise, every
|
||||
// message+part-count already agrees with the server. Skip the reactive
|
||||
// store churn entirely — virtualizer and rendering stay untouched.
|
||||
if (mode === "reconcile" && sameReconcileShape(store.messages[sessionID] ?? [], messages)) {
|
||||
if (
|
||||
mode === "reconcile" &&
|
||||
sameReconcileShape(store.messages[sessionID] ?? [], messages, (id) => store.parts[id])
|
||||
) {
|
||||
const parts = messageParts(messages)
|
||||
for (const msg of messages) {
|
||||
if (store.parts[msg.id]) delete parts[msg.id]
|
||||
@@ -1594,16 +1565,18 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
|
||||
const exists = (store.messages[message.sessionID] ?? []).some((msg) => msg.id === message.id)
|
||||
setStore("messages", message.sessionID, (msgs = []) => {
|
||||
if (message.sessionErrorID && msgs.some((msg) => msg.sessionErrorID === message.sessionErrorID)) return msgs
|
||||
const current = withoutResolvedSessionErrors(msgs, [message])
|
||||
// Check if message already exists (optimistic or update case).
|
||||
// Since we now use the same messageID for optimistic and server messages,
|
||||
// this naturally handles the optimistic→real transition.
|
||||
const idx = msgs.findIndex((m) => m.id === message.id)
|
||||
const idx = current.findIndex((m) => m.id === message.id)
|
||||
if (idx >= 0) {
|
||||
const updated = [...msgs]
|
||||
updated[idx] = { ...msgs[idx], ...message }
|
||||
const updated = [...current]
|
||||
updated[idx] = { ...current[idx], ...message }
|
||||
return updated
|
||||
}
|
||||
return [...msgs, message]
|
||||
return [...current, message]
|
||||
})
|
||||
patchPage(message.sessionID, { lastMutation: exists ? "update" : "append" })
|
||||
|
||||
|
||||
+2
@@ -443,6 +443,8 @@ export const dict = {
|
||||
"provider.custom.models.name.placeholder": "الاسم المعروض",
|
||||
"provider.custom.models.reasoning.label": "الاستدلال",
|
||||
"provider.custom.models.modalities.image": "صورة",
|
||||
"provider.custom.models.toggleReasoning": "تبديل التفكير للكل",
|
||||
"provider.custom.models.toggleImages": "تبديل الصور للكل",
|
||||
"provider.custom.models.remove": "إزالة النموذج",
|
||||
"provider.custom.models.add": "إضافة نموذج",
|
||||
"provider.custom.models.fetch.authError": "فشلت المصادقة. تحقق من مفتاح API أعلاه وحاول مرة أخرى.",
|
||||
|
||||
+2
@@ -454,6 +454,8 @@ export const dict = {
|
||||
"provider.custom.models.name.placeholder": "Nome de Exibição",
|
||||
"provider.custom.models.reasoning.label": "Raciocínio",
|
||||
"provider.custom.models.modalities.image": "Imagem",
|
||||
"provider.custom.models.toggleReasoning": "Alternar raciocínio para todos",
|
||||
"provider.custom.models.toggleImages": "Alternar imagem para todos",
|
||||
"provider.custom.models.remove": "Remover modelo",
|
||||
"provider.custom.models.add": "Adicionar modelo",
|
||||
"provider.custom.models.fetch.authError": "Falha na autenticação. Verifique a chave de API acima e tente novamente.",
|
||||
|
||||
+2
@@ -496,6 +496,8 @@ export const dict = {
|
||||
"provider.custom.models.name.placeholder": "Naziv za prikaz",
|
||||
"provider.custom.models.reasoning.label": "Zaključivanje",
|
||||
"provider.custom.models.modalities.image": "Slika",
|
||||
"provider.custom.models.toggleReasoning": "Uključi/isključi rezonovanje za sve",
|
||||
"provider.custom.models.toggleImages": "Uključi/isključi slike za sve",
|
||||
"provider.custom.models.remove": "Ukloni model",
|
||||
"provider.custom.models.add": "Dodaj model",
|
||||
"provider.custom.models.fetch.authError":
|
||||
|
||||
+2
@@ -494,6 +494,8 @@ export const dict = {
|
||||
"provider.custom.models.name.placeholder": "Visningsnavn",
|
||||
"provider.custom.models.reasoning.label": "Ræsonnement",
|
||||
"provider.custom.models.modalities.image": "Billede",
|
||||
"provider.custom.models.toggleReasoning": "Slå ræsonnement til/fra for alle",
|
||||
"provider.custom.models.toggleImages": "Slå billede til/fra for alle",
|
||||
"provider.custom.models.remove": "Fjern model",
|
||||
"provider.custom.models.add": "Tilføj model",
|
||||
"provider.custom.models.fetch.authError": "Godkendelse mislykkedes. Kontrollér API-nøglen ovenfor, og prøv igen.",
|
||||
|
||||
@@ -504,6 +504,8 @@ export const dict = {
|
||||
"provider.custom.models.name.placeholder": "Anzeigename",
|
||||
"provider.custom.models.reasoning.label": "Schlussfolgerung",
|
||||
"provider.custom.models.modalities.image": "Bild",
|
||||
"provider.custom.models.toggleReasoning": "Schlussfolgerung für alle umschalten",
|
||||
"provider.custom.models.toggleImages": "Bild für alle umschalten",
|
||||
"provider.custom.models.remove": "Modell entfernen",
|
||||
"provider.custom.models.add": "Modell hinzufügen",
|
||||
"provider.custom.models.fetch.authError":
|
||||
|
||||
@@ -408,6 +408,8 @@ export const dict = {
|
||||
"provider.custom.models.name.placeholder": "Display Name",
|
||||
"provider.custom.models.reasoning.label": "Reasoning",
|
||||
"provider.custom.models.modalities.image": "Image",
|
||||
"provider.custom.models.toggleReasoning": "Toggle reasoning for all",
|
||||
"provider.custom.models.toggleImages": "Toggle image for all",
|
||||
"provider.custom.models.remove": "Remove model",
|
||||
"provider.custom.models.add": "Add model",
|
||||
"provider.custom.models.fetch.authError": "Authentication failed. Check the API key above and try again.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user