feat(vscode): complete local session tab workflow

This commit is contained in:
marius-kilocode
2026-07-13 11:11:35 +02:00
parent 7585580909
commit d69d502805
42 changed files with 1316 additions and 442 deletions
+2 -1
View File
@@ -612,7 +612,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
connection: this.connectionService,
post: (msg: { type: "error"; message: string }) => this.postMessage(msg),
register: (session: Session) => this.registerSession(session),
forked: (session: Session) => this.postMessage({ type: "sessionForked", sessionID: session.id }),
forked: (session: Session, sourceID: string) =>
this.postMessage({ type: "sessionForked", sessionID: session.id, forkedFromID: sourceID }),
status: (sessionID: string) => this.sessionStatusMap.get(sessionID),
directory: (sessionID: string) => this.getWorkspaceDirectory(sessionID),
}
@@ -498,8 +498,13 @@ export class AgentManagerProvider implements Disposable {
}
if (m.type === "requestTerminalContext") {
if (m.sessionID && !this.terminalManager.hasActiveTerminal()) this.terminalManager.showExisting(m.sessionID)
return msg
if (!m.sessionID || this.terminalManager.prepareContext(m.sessionID)) return msg
this.panel?.postMessage({
type: "terminalContextError",
requestId: m.requestId,
error: "No terminal is associated with this session",
})
return null
}
if (m.type === "loadMessages") {
@@ -186,6 +186,21 @@ export class SessionTerminalManager {
return this.host.activeTerminal() !== undefined
}
activeSession(): string | undefined {
const active = this.host.activeTerminal()
if (!active) return undefined
for (const [id, entry] of this.terminals) {
if (entry.terminal === active && entry.terminal.exitStatus === undefined) return id
}
return undefined
}
prepareContext(sessionId: string): boolean {
if (this.showExisting(sessionId)) return true
const active = this.activeSession()
return !active || active === sessionId
}
dispose(): void {
void this.host.setContext("kilo-code.agentTerminalFocus", false)
for (const entry of this.terminals.values()) entry.terminal.dispose()
@@ -6,7 +6,7 @@ export interface ForkContext {
connection: KiloConnectionService
post: (message: { type: "error"; message: string }) => void
register: (session: Session) => void
forked: (session: Session) => void
forked: (session: Session, sourceID: string) => void
status: (sessionID: string) => SessionStatus["type"] | undefined
directory: (sessionID: string) => string
}
@@ -38,7 +38,7 @@ export async function handleForkSession(ctx: ForkContext, sessionId: string, mes
pushState: () => {},
notifyForked: (session) => {
ctx.register(session)
ctx.forked(session)
ctx.forked(session, sessionId)
},
registerSession: () => {},
log: (...args) => console.log("[Kilo New] KiloProvider:", ...args),
@@ -47,6 +47,7 @@ const TSX_FILES = [
// Shared components that consume agent-manager CSS classes (e.g. am-dropdown,
// am-branch-item) used by both the agent manager and the diff viewer.
path.join(ROOT, "webview-ui/src/components/shared/BranchSelect.tsx"),
path.join(ROOT, "webview-ui/src/components/chat/TabDnd.tsx"),
path.join(ROOT, "webview-ui/diff-viewer/BaseBranchPicker.tsx"),
]
const TSX_FILE = TSX_FILES[0]!
@@ -313,13 +314,12 @@ describe("Agent Manager Provider — onMessage routing", () => {
expect(text).toContain("syncOnSessionSwitch")
})
it("terminal context keeps the current active terminal when present", () => {
it("terminal context reveals the terminal associated with the originating session", () => {
const text = body("onSessionMessage")
const check = text.indexOf("!this.terminalManager.hasActiveTerminal()")
const show = text.indexOf("this.terminalManager.showExisting(m.sessionID)")
expect(check).toBeGreaterThan(-1)
const show = text.indexOf("this.terminalManager.prepareContext(m.sessionID)")
expect(show).toBeGreaterThan(-1)
expect(check, "active terminal check must guard session terminal reveal").toBeLessThan(show)
expect(text).not.toContain("!this.terminalManager.hasActiveTerminal()")
expect(text).toContain('type: "terminalContextError"')
})
it("session routing handles clearSession for SSE re-registration", () => {
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it } from "bun:test"
import {
beginPendingSend,
clearSessionDraftDiscarded,
deleteDraftsForSession,
discardPendingDraft,
drafts,
imageDrafts,
isPendingDraftDiscarded,
isSessionDraftDiscarded,
isPendingSend,
promotePendingDraftDiscard,
reviewDrafts,
savePromptDraft,
scrollDrafts,
finishPendingSend,
} from "../../webview-ui/src/utils/draft-store"
const stores = [drafts, reviewDrafts, imageDrafts, scrollDrafts]
beforeEach(() => stores.forEach((store) => store.clear()))
describe("prompt draft storage", () => {
it("stores and clears all prompt artifacts together", () => {
savePromptDraft(
"prompt:default:pending:sidebar-pending:1",
"draft",
[{ id: "review", file: "a.ts", side: "additions", line: 1, comment: "comment", selectedText: "line" }],
[{ id: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,a" }],
42,
)
expect(drafts.size).toBe(1)
expect(reviewDrafts.size).toBe(1)
expect(imageDrafts.size).toBe(1)
expect(scrollDrafts.size).toBe(1)
discardPendingDraft("sidebar-pending:1")
expect(stores.every((store) => store.size === 0)).toBe(true)
expect(isPendingDraftDiscarded("sidebar-pending:1")).toBe(true)
})
it("normalizes Agent Manager pending ids", () => {
savePromptDraft("agent-manager:local:pending:1", "draft", [], [], 3)
discardPendingDraft("pending:1")
expect(drafts.size).toBe(0)
expect(scrollDrafts.size).toBe(0)
})
it("promotes an in-flight discard marker to the created session", () => {
discardPendingDraft("pending:promotion")
expect(promotePendingDraftDiscard("pending:promotion", "s1")).toBe(true)
expect(isPendingDraftDiscarded("pending:promotion")).toBe(false)
expect(isSessionDraftDiscarded("s1")).toBe(true)
clearSessionDraftDiscarded("s1")
})
it("tracks pending work before backend submission starts", () => {
beginPendingSend("pending:attachment")
expect(isPendingSend("pending:attachment")).toBe(true)
finishPendingSend("pending:attachment")
expect(isPendingSend("pending:attachment")).toBe(false)
})
it("deletes session and pre-promotion pending keys", () => {
savePromptDraft("prompt:default:session:s1", "session", [], [], 1)
savePromptDraft("prompt:default:pending:s1", "pending", [], [], 2)
deleteDraftsForSession("s1")
expect(drafts.size).toBe(0)
expect(scrollDrafts.size).toBe(0)
})
})
@@ -1,9 +1,12 @@
import { describe, expect, it } from "bun:test"
import {
addPendingTab,
addSessionTab,
closeOtherTabs,
closeTab,
nextTabAfterClose,
openSessionTab,
insertSessionTabAfter,
pendingTabForCreated,
reconcileTabs,
reconcileTrackedTabs,
@@ -13,6 +16,7 @@ import {
showTabStrip,
type LocalTabState,
} from "../../webview-ui/src/utils/local-tabs"
import { reorderTabs } from "../../webview-ui/src/utils/tab-order"
const pending = (id = "sidebar-pending:1") => id
const makePending =
@@ -41,10 +45,11 @@ const reorder = (items: { id: string }[], order: string[]) => {
const inventory = (local: string[], external: string[] = []) => ({ local, external: new Set(external) })
describe("local session tabs", () => {
it("hides only the initial blank composer tab", () => {
it("hides the tab strip when only one tab remains", () => {
expect(showTabStrip([pending()])).toBe(false)
expect(showTabStrip([pending(), "sidebar-pending:2"])).toBe(true)
expect(showTabStrip(["s1"])).toBe(true)
expect(showTabStrip(["s1"])).toBe(false)
expect(showTabStrip(["s1", "s2"])).toBe(true)
})
it("restores a fresh pending tab when no sessions were persisted", () => {
@@ -72,6 +77,25 @@ describe("local session tabs", () => {
expect(openSessionTab(state(["s1", "s2"], "s1"), "s2")).toEqual({ ids: ["s1", "s2"], active: "s2" })
})
it("inserts a fork immediately after its source in a custom order", () => {
expect(insertSessionTabAfter(state(["s3", "s1", "s2"], "s3"), "s1", "fork")).toEqual({
ids: ["s3", "s1", "fork", "s2"],
active: "fork",
})
})
it("keeps repeated fork events idempotent", () => {
const current = state(["s3", "s1", "fork", "s2"], "s1")
expect(insertSessionTabAfter(current, "s1", "fork")).toEqual({ ids: current.ids, active: "fork" })
})
it("appends a fork when its source tab is no longer open", () => {
expect(insertSessionTabAfter(state(["s1", "s2"], "s1"), "missing", "fork")).toEqual({
ids: ["s1", "s2", "fork"],
active: "fork",
})
})
it("selects the neighboring tab after closing the active one", () => {
expect(closeTab(state(["s1", "s2", "s3"], "s2"), "s2", makePending())).toEqual({
ids: ["s1", "s3"],
@@ -97,9 +121,33 @@ describe("local session tabs", () => {
})
})
it("preserves a dragged pending tab position when it becomes a real session", () => {
const ids = reorderTabs(["s1", pending(), "s2"], pending(), "s1")!
expect(replacePendingTab(state(ids, pending()), pending(), "s3")).toEqual({
ids: ["s3", "s1", "s2"],
active: "s3",
})
})
it("does not replace another pending tab when an explicit draft was closed", () => {
expect(pendingTabForCreated(["sidebar-pending:2"], "sidebar-pending:2", "sidebar-pending:1")).toBeUndefined()
expect(pendingTabForCreated(["sidebar-pending:2"], "sidebar-pending:2", undefined)).toBe("sidebar-pending:2")
expect(pendingTabForCreated(["sidebar-pending:2"], "sidebar-pending:1")).toBeUndefined()
expect(pendingTabForCreated(["sidebar-pending:2"], undefined)).toBeUndefined()
})
it("adds a background session without changing the active tab", () => {
expect(addSessionTab(state(["s1"], "s1"), "s2")).toEqual({ ids: ["s1", "s2"], active: "s1" })
})
it("closes every other tab and activates the retained tab", () => {
expect(closeOtherTabs(state(["s1", pending(), "s2"], "s1"), pending())).toEqual({
ids: [pending()],
active: pending(),
})
})
it("does not close tabs when the retained id is missing", () => {
const current = state(["s1", "s2"], "s1")
expect(closeOtherTabs(current, "missing")).toBe(current)
})
})
@@ -12,12 +12,14 @@ const icons = readFileSync(iconPath, "utf8")
describe("PromptInput connection guard", () => {
it("rechecks the connection after resolving async attachments and before clearing the draft", () => {
const attachments = src.indexOf("const gitFile = await git.resolveAttachment")
const guard = src.indexOf("if (isDisabled()) return", attachments)
const guard = src.indexOf("if (isDisabled()) {", attachments)
const finish = src.indexOf("finishPending(pendingId)", guard)
const send = src.indexOf("session.sendMessage(message", guard)
const clear = src.indexOf("drafts.delete(key)", send)
expect(attachments).toBeGreaterThan(-1)
expect(guard).toBeGreaterThan(attachments)
expect(finish).toBeGreaterThan(guard)
expect(send).toBeGreaterThan(guard)
expect(clear).toBeGreaterThan(send)
})
@@ -59,15 +61,15 @@ describe("PromptInput sandbox toggle", () => {
expect(end).toBeGreaterThan(start)
expect(save).toBeGreaterThan(-1)
expect(move).toBeGreaterThan(save)
expect(created).toContain("{ text: drafts, comments: reviewDrafts, images: imageDrafts, scrolls }")
expect(created).toContain("{ text: drafts, comments: reviewDrafts, images: imageDrafts, scrolls: scrollDrafts }")
})
it("restores each prompt draft's textarea and highlight scroll positions", () => {
expect(src).toContain("const scrolls = new Map<string, number>()")
expect(src).toContain("const scroll = scrolls.get(key) ?? 0")
expect(src).toContain("scrollDrafts")
expect(src).toContain("const scroll = scrollDrafts.get(key) ?? 0")
expect(src).toContain("textareaRef.scrollTop = scroll")
expect(src).toContain("if (highlightRef) highlightRef.scrollTop = scroll")
expect(src).toContain("scrolls.set(draftKey(), textareaRef.scrollTop)")
expect(src).toContain("scrollDrafts.set(draftKey(), textareaRef.scrollTop)")
expect(src).toContain("images: imageAttach.images(),\n scroll: textareaRef?.scrollTop")
expect(src).toContain("draft.text, draft.comments, draft.images, draft.scroll")
})
@@ -19,6 +19,7 @@ const ROOT = path.resolve(import.meta.dir, "../..")
const SESSION_FILE = path.join(ROOT, "webview-ui/src/context/session.tsx")
const CHATVIEW_FILE = path.join(ROOT, "webview-ui/src/components/chat/ChatView.tsx")
const PROMPT_UTILS_FILE = path.join(ROOT, "webview-ui/src/components/chat/prompt-input-utils.ts")
const PROMPT_FILE = path.join(ROOT, "webview-ui/src/components/chat/PromptInput.tsx")
const KILOPROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts")
const CONNECTION_SERVICE_FILE = path.join(ROOT, "src/services/cli-backend/connection-service.ts")
@@ -246,55 +247,68 @@ describe("sendMessage / sendCommand draft id contract", () => {
expect(draftBlock![1]).not.toContain("setPendingAgentSelection(null)")
})
it("only selects a created session when its explicit draft is still active", () => {
const body = extractFunctionBody(source, "handleSessionCreated")
expect(body).toMatch(/if \(draftID && \(draft === draftID \|\| active === draftID\)\)/)
expect(body).not.toMatch(/if \(!draftID \|\|/)
})
it("prunes seeded draft agents only after the draft is abandoned", () => {
const failed = extractFunctionBody(source, "handleSendMessageFailed")
expect(source).toMatch(/const agentDrafts = createDraftAgentSeed/)
expect(source).toContain("active: (draft) => !!submissionMap[draft]")
expect(failed).toContain("draftSessionID() !== message.draftID")
expect(failed).toContain("agentDrafts.prune(message.draftID)")
expect(failed).not.toContain("setDraftSessionID(message.draftID)")
})
})
describe("PromptInput restoreFailed fallback contract", () => {
const PROMPT_FILE = path.join(ROOT, "webview-ui/src/components/chat/PromptInput.tsx")
const source = readFile(PROMPT_FILE)
it("targets draftKey() instead of computing a key from failed.sessionID", () => {
// The contract: restoreFailed early-returns when userClearedSession is
// true (covers BOTH "user clicked New Task" and the Delete-current-session
// race window where currentSessionID/draftSessionID haven't been cleared
// yet but userClearedSession is already true). When the user did NOT
// explicitly clear, candidates come from the failure's sessionID/draftID
// (the keys the send was actually scoped to), plus :new ONLY when the
// user has effectively returned to the empty state via an external
// session.deleted.
it("stores a failed payload under its originating session or pending draft key", () => {
const match = source.match(/const restoreFailed = \(failed: SendMessageFailedMessage\) => \{([\s\S]*?)\n \}/)
expect(match).not.toBeNull()
expect(match![1]).not.toMatch(/const effectiveSessionID/)
expect(match![1]).toMatch(/if \(session\.userClearedSession\(\)\) return/)
expect(match![1]).toMatch(
/if \(failed\.sessionID\) candidates\.add\(scopeDraftKey\(boxKey\(\),\s*sessionDraftKey\(failed\.sessionID\)\)\)/,
/failed\.sessionID\s*\? scopeDraftKey\(boxKey\(\), sessionDraftKey\(failed\.sessionID\)\)/,
)
expect(match![1]).toMatch(
/if \(failed\.draftID\) candidates\.add\(scopeDraftKey\(boxKey\(\),\s*pendingDraftKey\(failed\.draftID\)\)\)/,
)
expect(match![1]).toMatch(
/if \(!session\.currentSessionID\(\) && !session\.draftSessionID\(\)\) candidates\.add\(scopeDraftKey\(boxKey\(\),\s*"new"\)\)/,
)
expect(match![1]).toMatch(/const target = draftKey\(\)/)
expect(match![1]).toMatch(/candidates\.has\(target\)/)
expect(match![1]).toMatch(/failed\.draftID\s*\? scopeDraftKey\(boxKey\(\), pendingDraftKey\(failed\.draftID\)\)/)
expect(match![1]).toContain("if (target !== draftKey())")
expect(match![1]).toContain("saveDraft(target, draft, comments, images")
})
it("does NOT add :new when the user is on a different live session or pending draft", () => {
// Guard against the unconditional-:new regression: if the user has
// navigated to a different session/pending draft, the failed draft
// must NOT be rehydrated into that unrelated prompt even if the
// failure carries scope IDs that no longer match the live state.
it("does not restore a late failure for a discarded pending tab", () => {
const match = source.match(/const restoreFailed = \(failed: SendMessageFailedMessage\) => \{([\s\S]*?)\n \}/)
expect(match).not.toBeNull()
expect(match![1]).not.toMatch(
/if \(!failed\.sessionID && !failed\.draftID\) candidates\.add\(scopeDraftKey\(boxKey\(\),\s*"new"\)\)/,
expect(match![1]).toContain("isPendingDraftDiscarded(failed.draftID)")
expect(match![1]).toContain("isSessionDraftDiscarded(failed.sessionID)")
})
it("retires a discarded real-session marker only after confirmed assistant output", () => {
const session = readFile(SESSION_FILE)
const created = extractFunctionBody(session, "handleMessageCreated")
const status = extractFunctionBody(session, "handleSessionStatus")
expect(created).toContain('message.role === "assistant"')
expect(created).toContain("clearSessionDraftDiscarded(message.sessionID)")
expect(status).not.toContain("clearSessionDraftDiscarded")
})
})
describe("PromptInput send origin contract", () => {
const source = readFile(PROMPT_FILE)
it("captures the real or pending tab before asynchronous attachment resolution", () => {
expect(source).toMatch(/const origin = session\.currentSessionID\(\)[\s\S]*const id = origin \?\? pendingId/)
expect(source.indexOf("beginPendingSend(pendingId)")).toBeLessThan(
source.indexOf("await terminal.resolveAttachment"),
)
expect(source).toMatch(/await terminal\.resolveAttachment\(message, id\)/)
expect(source).toMatch(/await git\.resolveAttachment\(message, id, context\)/)
})
it("passes the captured origin to message and command sends", () => {
expect(source).toMatch(/session\.sendMessage\([\s\S]*origin \?\? null\)/)
expect(source).toMatch(/session\.sendCommand\([\s\S]*origin \?\? null\)/)
})
})
@@ -352,7 +366,7 @@ describe("SessionContext userClearedSession contract", () => {
// that window: the failure is for the current in-progress draft and must
// be restorable.
const body = extractFunctionBody(source, "sendMessage")
const block = body.match(/if \(!sid\) \{([\s\S]*?)\}/)
const block = body.match(/if \(!sid && \(!draftID \|\| draftSessionID\(\) === scope\)\) \{([\s\S]*?)\}/)
expect(block).not.toBeNull()
expect(block![1]).toMatch(/setUserClearedSession\(false\)/)
expect(block![1]).toMatch(/setDraftSessionID\(scope\)/)
@@ -360,7 +374,7 @@ describe("SessionContext userClearedSession contract", () => {
it("sendCommand resets userClearedSession when starting a fresh draft from :new", () => {
const body = extractFunctionBody(source, "sendCommand")
const block = body.match(/if \(!sid\) \{([\s\S]*?)\}/)
const block = body.match(/if \(!sid && \(!draftID \|\| draftSessionID\(\) === scope\)\) \{([\s\S]*?)\}/)
expect(block).not.toBeNull()
expect(block![1]).toMatch(/setUserClearedSession\(false\)/)
expect(block![1]).toMatch(/setDraftSessionID\(scope\)/)
@@ -417,6 +431,17 @@ describe("Cloud import parts cleanup contract", () => {
expect(body).toMatch(/pendingCloudPrune\.delete\(cloudKey\)/)
})
it("selecting a local session clears cloud preview mode", () => {
const body = extractFunctionBody(source, "selectSession")
expect(body).toContain("setCloudPreviewId(null)")
})
it("a late cloud import only selects its real session while the same preview remains active", () => {
const body = extractFunctionBody(source, "handleCloudSessionImported")
expect(body).toMatch(/const active = cloudPreviewId\(\) === cloudSessionId && currentSessionID\(\) === cloudKey/)
expect(body).toMatch(/if \(active\) \{[\s\S]*setCurrentSessionID\(session\.id\)/)
})
it("handleMessagesLoaded prunes cloud-import orphans from store.parts and stash", () => {
// The carried-over cloud messages are gone from store.messages after
// this call, so any store.parts[<cloud-msg-id>] entry is unreachable.
@@ -134,6 +134,18 @@ describe("SessionTerminalManager structure", () => {
const text = body("hasActiveTerminal")
expect(text).toContain("this.host.activeTerminal()")
})
it("resolves the session that owns the active managed terminal", () => {
const text = body("activeSession")
expect(text).toContain("this.host.activeTerminal()")
expect(text).toContain("entry.terminal === active")
})
it("rejects context capture from another managed session", () => {
const text = body("prepareContext")
expect(text).toContain("this.showExisting(sessionId)")
expect(text).toContain("this.activeSession()")
})
})
describe("SessionTerminalManager command restoration", () => {
@@ -0,0 +1,46 @@
import { describe, expect, it, mock } from "bun:test"
import type { Session } from "@kilocode/sdk/v2/client"
import { handleForkSession, type ForkContext } from "../../src/kilo-provider/fork-session"
const session = { id: "fork", title: "fork", createdAt: "", updatedAt: "" } as Session
function ctx(overrides: Partial<ForkContext> = {}): ForkContext {
const client = {
session: {
fork: mock(async () => ({ data: session })),
promptAsync: mock(async () => ({})),
},
}
return {
connection: { getClient: () => client } as never,
post: () => undefined,
register: () => undefined,
forked: () => undefined,
status: () => "idle",
directory: () => "/repo",
...overrides,
}
}
describe("sidebar fork session", () => {
it("registers the fork before reporting its source tab", async () => {
const order: string[] = []
const forked = mock((_session: Session, sourceID: string) => order.push(`forked:${sourceID}`))
const register = mock(() => order.push("registered"))
await handleForkSession(ctx({ forked, register }), "source", "message")
expect(order).toEqual(["registered", "forked:source"])
expect(forked).toHaveBeenCalledWith(session, "source")
})
it("rejects a non-idle source before forking", async () => {
const forked = mock(() => undefined)
const post = mock(() => undefined)
await handleForkSession(ctx({ forked, post, status: () => "busy" }), "source")
expect(forked).not.toHaveBeenCalled()
expect(post).toHaveBeenCalledWith({ type: "error", message: "Wait for the session to finish before forking it." })
})
})
@@ -0,0 +1,39 @@
import { describe, expect, it } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"
const root = join(__dirname, "..", "..", "webview-ui", "src")
const strip = readFileSync(join(root, "components", "chat", "SessionTabStrip.tsx"), "utf8")
const tabs = readFileSync(join(root, "context", "local-tabs.tsx"), "utf8")
describe("sidebar tab drag ordering", () => {
it("uses shared pointer DnD and sortable tab primitives", () => {
expect(strip).toContain("<DragDropProvider")
expect(strip).toContain("<DragDropSensors />")
expect(strip).toContain("<ConstrainDragYAxis />")
expect(strip).toContain("<SortableProvider ids={tabs.ids()}>")
expect(strip).toContain("<SortableTabContainer id={id}>")
})
it("reorders while dragging and persists on drag end", () => {
expect(strip).toContain("tabs.reorder(from, to)")
expect(strip).toMatch(/const dragEnd = \(\) => \{[\s\S]*tabs\.persist\(\)/)
})
it("supports keyboard reorder without replacing selection navigation", () => {
expect(strip).toContain('tabs.move(id, event.key === "ArrowLeft" ? -1 : 1)')
expect(strip).toContain("handleTabKey({ ids: tabs.ids(), id, event, select: tabs.select, root })")
expect(strip).toContain('aria-live="polite"')
})
it("persists real order and active tab through VS Code webview state", () => {
expect(tabs).toContain("sidebarSessionTabIDs: tabs")
expect(tabs).toContain("sidebarActiveSessionTabID: selected")
expect(tabs).toContain("timer = setTimeout(persist, 300)")
})
it("releases frozen widths after closing and after dragging", () => {
expect(strip.match(/requestAnimationFrame\(release\)/g)).toHaveLength(2)
expect(strip).toMatch(/const dragEnd = \(\) => \{[\s\S]*release\(\)/)
})
})
@@ -0,0 +1,43 @@
import { describe, expect, it } from "bun:test"
import { handleTabKey, tabForKey } from "../../webview-ui/src/utils/tab-navigation"
describe("tab keyboard navigation", () => {
const ids = ["first", "middle", "last"]
it("wraps arrow navigation", () => {
expect(tabForKey(ids, "first", "ArrowLeft")).toBe("last")
expect(tabForKey(ids, "last", "ArrowRight")).toBe("first")
})
it("moves to the bounds with Home and End", () => {
expect(tabForKey(ids, "middle", "Home")).toBe("first")
expect(tabForKey(ids, "middle", "End")).toBe("last")
})
it("ignores unrelated keys and missing tabs", () => {
expect(tabForKey(ids, "middle", "Enter")).toBeUndefined()
expect(tabForKey(ids, "missing", "ArrowRight")).toBeUndefined()
})
it("does not treat modified arrows as standard tab navigation", () => {
const target = {}
const selected: string[] = []
handleTabKey({
ids,
id: "middle",
event: {
key: "ArrowRight",
metaKey: true,
ctrlKey: false,
shiftKey: true,
altKey: false,
target,
currentTarget: target,
preventDefault: () => undefined,
} as unknown as KeyboardEvent,
select: (id) => selected.push(id),
root: null,
})
expect(selected).toEqual([])
})
})
@@ -6,6 +6,7 @@ import {
replaceInTabOrder,
insertInTabOrderAfter,
} from "../../webview-ui/agent-manager/tab-order"
import { moveTab } from "../../webview-ui/src/utils/tab-order"
describe("reorderTabs", () => {
const tabs = ["a", "b", "c", "d"]
@@ -82,6 +83,15 @@ describe("reorderTabs", () => {
})
})
describe("moveTab", () => {
it("moves one position without wrapping", () => {
expect(moveTab(["a", "b", "c"], "b", -1)).toEqual(["b", "a", "c"])
expect(moveTab(["a", "b", "c"], "b", 1)).toEqual(["a", "c", "b"])
expect(moveTab(["a", "b", "c"], "a", -1)).toBeUndefined()
expect(moveTab(["a", "b", "c"], "c", 1)).toBeUndefined()
})
})
describe("applyTabOrder", () => {
const items = [
{ id: "a", name: "Alice" },
@@ -93,6 +93,7 @@ import { DataBridge, MermaidDownloadBridge } from "../src/App"
import { LanguageBridge } from "../src/context/language-bridge"
import { useLanguage } from "../src/context/language"
import { formatRelativeDate } from "../src/utils/date"
import { createTabFocus } from "../src/utils/tab-navigation"
import { nextSelectionAfterDelete, adjacentHint, filterUnassignedSessions, LOCAL } from "./navigate"
import {
addPendingTab as addLocalPendingTab,
@@ -102,10 +103,16 @@ import {
replacePendingTab,
restoreTrackedTabs,
} from "../src/utils/local-tabs"
import {
deletePendingDraft,
discardPendingDraft,
isPendingSend,
promotePendingDraftDiscard,
} from "../src/utils/draft-store"
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "./tab-order"
import { createTabOrderSync } from "./tab-order-sync"
import { reportRemoteSessions } from "./remote-sessions"
import { ConstrainDragYAxis } from "./sortable-tab"
import { ConstrainDragYAxis } from "../src/components/chat/TabDnd"
import { isTerminalTabId, createTerminalState, createTerminalHandlers, createTerminalMessageHandler } from "./terminal"
import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering"
import { useTabScroll } from "./tab-scroll"
@@ -1158,9 +1165,13 @@ const AgentManagerContent: Component = () => {
// Add created sessions as local tabs (both direct from the prompt and
// backend follow-ups). Dedups HTTP + SSE firing together.
const createdSessions = new Set<string>()
const unsubCreate = vscode.onMessage((msg) => {
if (msg.type !== "sessionCreated") return
const created = msg as SessionCreatedMessage
if (!created.draftID && createdSessions.delete(created.session.id)) return
if (created.draftID) createdSessions.add(created.session.id)
if (created.draftID && promotePendingDraftDiscard(created.draftID, created.session.id)) return
const pending = created.draftID && localSessionIDs().includes(created.draftID) ? created.draftID : undefined
if (!pending && localSessionIDs().includes(created.session.id)) return
if (worktreeSessionIds().has(created.session.id)) return
@@ -1576,6 +1587,7 @@ const AgentManagerContent: Component = () => {
freezeTabs()
setReviewActive(false)
setReviewOpenForSelection(false)
tabFocus.restore()
}
// Data for the review tab: use local diff data for local context,
@@ -1956,6 +1968,11 @@ const AgentManagerContent: Component = () => {
} else {
vscode.postMessage({ type: "agentManager.closeSession", sessionId })
}
if (pending) {
if (session.isSubmitting(sessionId) || isPendingSend(sessionId)) discardPendingDraft(sessionId)
queueMicrotask(() => deletePendingDraft(sessionId))
}
tabFocus.restore()
}
const handleTabMouseDown = (sessionId: string, e: MouseEvent) => {
@@ -2089,11 +2106,15 @@ const AgentManagerContent: Component = () => {
selectSession: session.selectSession,
activateTerminal: termHandlers.activate,
})
const tabFocus = createTabFocus({ ids: () => tabIds(), select: focusTab })
// Close the currently active tab via keyboard shortcut.
// If no tabs remain, fall through to close the selected worktree.
const closeActiveTab = () => {
if (termHandlers.closeActive()) return
if (termHandlers.closeActive()) {
tabFocus.restore()
return
}
if (reviewActive()) {
closeReviewTab()
return
@@ -2640,6 +2661,8 @@ const AgentManagerContent: Component = () => {
<div
class="am-tab-list"
ref={tabScroll.setRef}
role="tablist"
aria-label={t("agentManager.shortcuts.category.tabs")}
style={{ "--tab-count": `${tabIds().length}` } as JSX.CSSProperties}
>
<SortableProvider ids={tabIds()}>
@@ -2660,8 +2683,9 @@ const AgentManagerContent: Component = () => {
adjacentHint,
activateTerminal: termHandlers.activate,
deactivateTerminal: termHandlers.deactivate,
closeTerminal: termHandlers.closeTerminal,
terminalMiddleClick: termHandlers.middleClick,
closeTerminal: (id) => tabFocus.run(() => termHandlers.closeTerminal(id)),
terminalMiddleClick: (id, event) =>
tabFocus.middle(event, () => termHandlers.middleClick(id, event)),
closeReview: closeReviewTab,
reviewMiddleClick: handleReviewTabMouseDown,
selectReviewTab: () => setReviewActive(true),
@@ -2669,6 +2693,7 @@ const AgentManagerContent: Component = () => {
sessionMiddleClick: handleTabMouseDown,
sessionClose: handleCloseTab,
sessionFork: handleForkSession,
onTabKey: tabFocus.key,
reviewLabel: t("session.tab.review"),
reviewTooltip: t("command.review.toggle"),
})
@@ -1251,6 +1251,19 @@ button.am-section-toggle:hover .am-section-label {
background: color-mix(in srgb, var(--surface-interactive-base) 10%, transparent);
}
.am-tab-target {
display: flex;
align-items: center;
min-width: 0;
height: 100%;
flex: 1;
}
.am-tab:has(.am-tab-target:focus-visible) {
background: var(--button-ghost-hover, var(--surface-base-hover, rgba(128, 128, 128, 0.2)));
color: var(--text-base);
}
.am-tab-icon {
display: inline-flex;
align-items: center;
@@ -1286,8 +1299,6 @@ button.am-section-toggle:hover .am-section-label {
min-width: 0;
height: 100%;
flex: 1;
padding-right: 27px;
margin-right: -27px;
}
.am-tab-close-wrap {
@@ -2,46 +2,18 @@
* Drag-and-drop sortable tab components for the agent manager tab bar.
*/
declare module "solid-js" {
namespace JSX {
interface Directives {
sortable: true
}
}
}
import { Component, onCleanup, Show } from "solid-js"
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd"
import type { Transformer } from "@thisbeyond/solid-dnd"
import { createRoot } from "solid-js"
import { Component } from "solid-js"
import type { JSX } from "solid-js"
import type { SessionInfo } from "../src/types/messages"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Icon } from "@kilocode/kilo-ui/icon"
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { ContextMenu } from "@kilocode/kilo-ui/context-menu"
import { useLanguage } from "../src/context/language"
import { SessionTab } from "../src/components/chat/SessionTab"
import { SessionTabMenu } from "../src/components/chat/SessionTabMenu"
import { SortableTabContainer } from "../src/components/chat/TabDnd"
import { parseBindingTokens } from "./keybind-tokens"
/** Lock drag movement to the X axis (horizontal-only tab dragging). */
export const ConstrainDragYAxis: Component = () => {
const context = useDragDropContext()
if (!context) return null
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
const transformer: Transformer = { id: "constrain-y-axis", order: 100, callback: (t) => ({ ...t, y: 0 }) }
const dispose = createRoot((dispose) => {
onDragStart(({ draggable }) => {
if (draggable) addTransformer("draggables", draggable.id as string, transformer)
})
onDragEnd(({ draggable }) => {
if (draggable) removeTransformer("draggables", draggable.id as string, transformer.id)
})
return dispose
})
onCleanup(dispose)
return null
}
/** Individual sortable tab wrapper using the `use:sortable` directive. */
export const SortableTab: Component<{
tab: SessionInfo
@@ -54,61 +26,48 @@ export const SortableTab: Component<{
onClose: () => void
onCloseOthers: () => void
onFork?: () => void
role?: "tab"
selected?: boolean
tabIndex?: number
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
}> = (props) => {
const { t } = useLanguage()
const sortable = createSortable(props.tab.id)
// Prevent tree-shaking of the directive reference used by `use:sortable`
void sortable
return (
<div
use:sortable
class={`am-tab-sortable ${sortable.isActiveDraggable ? "am-tab-dragging" : ""}`}
data-tab-id={props.tab.id}
>
<ContextMenu>
<ContextMenu.Trigger as="div" style={{ display: "contents" }}>
<SessionTab
title={props.tab.title || t("agentManager.session.untitled")}
active={props.active}
busy={props.busy}
keybind={props.keybind}
closeKeybind={props.closeKeybind}
closeTabIndex={props.active ? 0 : -1}
closeTitle={t("agentManager.tab.close")}
closeLabel={t("agentManager.tab.closeTab")}
onSelect={props.onSelect}
onMiddleClick={props.onMiddleClick}
onClose={props.onClose}
/>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content class="am-ctx-menu">
<Show when={props.onFork}>
<ContextMenu.Item onSelect={() => props.onFork?.()}>
<Icon name="fork" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.tab.forkSession")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Separator />
</Show>
<ContextMenu.Item onSelect={props.onClose}>
<Icon name="close" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.tab.close")}</ContextMenu.ItemLabel>
<Show when={props.closeKeybind}>
<span class="am-menu-shortcut">
{parseBindingTokens(props.closeKeybind ?? "").map((token) => (
<kbd class="am-menu-key">{token}</kbd>
))}
</span>
</Show>
</ContextMenu.Item>
<ContextMenu.Item onSelect={props.onCloseOthers}>
<Icon name="close" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.tab.closeOthers")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
</div>
<SortableTabContainer id={props.tab.id}>
<SessionTabMenu
showFork
onFork={props.onFork}
onClose={props.onClose}
onCloseOthers={props.onCloseOthers}
closeShortcut={
props.closeKeybind ? (
<span class="am-menu-shortcut">
{parseBindingTokens(props.closeKeybind).map((token) => (
<kbd class="am-menu-key">{token}</kbd>
))}
</span>
) : undefined
}
>
<SessionTab
title={props.tab.title || t("agentManager.session.untitled")}
active={props.active}
busy={props.busy}
keybind={props.keybind}
closeKeybind={props.closeKeybind}
closeTabIndex={props.active ? 0 : -1}
role={props.role}
selected={props.selected}
tabIndex={props.tabIndex}
onKeyDown={props.onKeyDown}
closeTitle={t("agentManager.tab.close")}
closeLabel={t("agentManager.tab.closeTab")}
onSelect={props.onSelect}
onMiddleClick={props.onMiddleClick}
onClose={props.onClose}
/>
</SessionTabMenu>
</SortableTabContainer>
)
}
@@ -120,41 +79,44 @@ export const SortableReviewTab: Component<{
keybind?: string
closeKeybind?: string
active: boolean
role?: "tab"
selected?: boolean
tabIndex?: number
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
onSelect: () => void
onMiddleClick: (e: MouseEvent) => void
onClose: (e: MouseEvent) => void
}> = (props) => {
const { t } = useLanguage()
const sortable = createSortable(props.id)
// Prevent tree-shaking of the directive reference used by `use:sortable`
void sortable
return (
<div
use:sortable
class={`am-tab-sortable ${sortable.isActiveDraggable ? "am-tab-dragging" : ""}`}
data-tab-id={props.id}
>
<div
class={`am-tab am-tab-review ${props.active ? "am-tab-active" : ""}`}
onClick={props.onSelect}
onMouseDown={props.onMiddleClick}
>
<TooltipKeybind
title={props.tooltip}
keybind={props.keybind ?? ""}
placement="bottom"
gutter={8}
class="am-tab-tooltip"
openDelay={0}
<SortableTabContainer id={props.id}>
<div class={`am-tab am-tab-review ${props.active ? "am-tab-active" : ""}`}>
<div
class="am-tab-target"
role={props.role}
aria-selected={props.selected}
tabIndex={props.tabIndex}
onClick={props.onSelect}
onMouseDown={props.onMiddleClick}
onKeyDown={props.onKeyDown}
>
<span class="am-tab-title">
<span class="am-tab-icon">
<Icon name="layers" size="small" />
<TooltipKeybind
title={props.tooltip}
keybind={props.keybind ?? ""}
placement="bottom"
gutter={8}
class="am-tab-tooltip"
openDelay={0}
>
<span class="am-tab-title">
<span class="am-tab-icon">
<Icon name="layers" size="small" />
</span>
<span class="am-tab-label">{props.label}</span>
</span>
<span class="am-tab-label">{props.label}</span>
</span>
</TooltipKeybind>
</TooltipKeybind>
</div>
<TooltipKeybind
title={t("agentManager.tab.close")}
keybind={props.closeKeybind ?? ""}
@@ -167,12 +129,13 @@ export const SortableReviewTab: Component<{
icon="close-small"
size="small"
variant="ghost"
label={t("agentManager.tab.closeTab")}
aria-label={t("agentManager.tab.closeTab")}
tabIndex={props.active ? 0 : -1}
class="am-tab-close"
onClick={props.onClose}
/>
</TooltipKeybind>
</div>
</div>
</SortableTabContainer>
)
}
@@ -2,20 +2,7 @@
* Pure tab-ordering logic for the agent manager.
*/
/**
* Reorder an array by moving the item at `from` to the position of `to`.
* Returns a new array, or undefined if either ID is not found or they are equal.
*/
export function reorderTabs(tabs: readonly string[], from: string, to: string): string[] | undefined {
if (from === to) return undefined
const fi = tabs.indexOf(from)
const ti = tabs.indexOf(to)
if (fi === -1 || ti === -1) return undefined
const result = [...tabs]
result.splice(fi, 1)
result.splice(ti, 0, from)
return result
}
export { reorderTabs } from "../src/utils/tab-order"
/**
* Apply a custom ordering to a list of items.
@@ -87,6 +87,7 @@ export interface TabRenderDeps {
sessionMiddleClick: (id: string, e: MouseEvent) => void
sessionClose: (id: string) => void
sessionFork: (id: string) => void
onTabKey: (id: string, event: KeyboardEvent) => void
reviewLabel: string
reviewTooltip: string
}
@@ -114,6 +115,10 @@ export function renderTab(id: string, deps: TabRenderDeps): JSX.Element {
onMiddleClick: deps.terminalMiddleClick,
onClose: deps.closeTerminal,
onCloseOthers: (target) => closeOthers(target, deps),
role: "tab",
selected: deps.visibleTabId() === id,
tabIndex: deps.visibleTabId() === id ? 0 : -1,
onKeyDown: (event) => deps.onTabKey(id, event),
})
}
if (id === deps.REVIEW_TAB_ID) return renderReviewTab(deps)
@@ -140,6 +145,10 @@ function renderReviewTab(deps: TabRenderDeps): JSX.Element {
keybind={keybind}
closeKeybind={deps.kb().closeTab ?? ""}
active={deps.reviewActive() && !deps.terms.activeId()}
role="tab"
selected={deps.visibleTabId() === deps.REVIEW_TAB_ID}
tabIndex={deps.visibleTabId() === deps.REVIEW_TAB_ID ? 0 : -1}
onKeyDown={(event) => deps.onTabKey(deps.REVIEW_TAB_ID, event)}
onSelect={() => {
deps.deactivateTerminal()
deps.selectReviewTab()
@@ -173,6 +182,10 @@ function renderSessionTab(s: SessionInfo, deps: TabRenderDeps): JSX.Element {
tab={s}
active={active() && !deps.reviewActive()}
busy={deps.isBusy(s.id)}
role="tab"
selected={deps.visibleTabId() === s.id}
tabIndex={deps.visibleTabId() === s.id ? 0 : -1}
onKeyDown={(event) => deps.onTabKey(s.id, event)}
keybind={keybind()}
closeKeybind={deps.kb().closeTab ?? ""}
onSelect={() => {
@@ -1,21 +1 @@
export function setTabWidths(frozen: boolean, root: ParentNode = document) {
const list = root.querySelector(".am-tab-list")
if (!(list instanceof HTMLElement)) return
list.toggleAttribute("data-tab-widths-frozen", frozen)
const tabs = Array.from(list.children).filter((child): child is HTMLElement => child instanceof HTMLElement)
for (const tab of tabs) {
if (frozen) {
const width = tab.getBoundingClientRect().width
tab.style.width = `${width}px`
tab.style.minWidth = `${width}px`
tab.style.flex = `0 0 ${width}px`
tab.style.maxWidth = `${width}px`
continue
}
tab.style.width = ""
tab.style.minWidth = ""
tab.style.flex = ""
tab.style.maxWidth = ""
}
}
export { setTabWidths } from "../src/utils/tab-widths"
@@ -6,21 +6,13 @@
* hints and context actions regardless of tab kind.
*/
declare module "solid-js" {
namespace JSX {
interface Directives {
sortable: true
}
}
}
import { Component, Show } from "solid-js"
import { createSortable } from "@thisbeyond/solid-dnd"
import { Component, Show, type JSX } from "solid-js"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Icon } from "@kilocode/kilo-ui/icon"
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { ContextMenu } from "@kilocode/kilo-ui/context-menu"
import { useLanguage } from "../../src/context/language"
import { SortableTabContainer } from "../../src/components/chat/TabDnd"
import { parseBindingTokens } from "../keybind-tokens"
export const SortableTerminalTab: Component<{
@@ -30,42 +22,46 @@ export const SortableTerminalTab: Component<{
keybind?: string
closeKeybind?: string
active: boolean
role?: "tab"
selected?: boolean
tabIndex?: number
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
onSelect: () => void
onMiddleClick: (e: MouseEvent) => void
onClose: (e: MouseEvent) => void
onCloseOthers: () => void
}> = (props) => {
const { t } = useLanguage()
const sortable = createSortable(props.id)
void sortable
return (
<div
use:sortable
class={`am-tab-sortable ${sortable.isActiveDraggable ? "am-tab-dragging" : ""}`}
data-tab-id={props.id}
>
<SortableTabContainer id={props.id}>
<ContextMenu>
<ContextMenu.Trigger as="div" style={{ display: "contents" }}>
<div
class={`am-tab am-tab-terminal ${props.active ? "am-tab-active" : ""}`}
onClick={props.onSelect}
onMouseDown={props.onMiddleClick}
>
<TooltipKeybind
title={props.tooltip}
keybind={props.keybind ?? ""}
placement="bottom"
gutter={8}
class="am-tab-tooltip"
openDelay={0}
<div class={`am-tab am-tab-terminal ${props.active ? "am-tab-active" : ""}`}>
<div
class="am-tab-target"
role={props.role}
aria-selected={props.selected}
tabIndex={props.tabIndex}
onClick={props.onSelect}
onMouseDown={props.onMiddleClick}
onKeyDown={props.onKeyDown}
>
<span class="am-tab-title">
<span class="am-tab-icon">
<Icon name="console" size="small" />
<TooltipKeybind
title={props.tooltip}
keybind={props.keybind ?? ""}
placement="bottom"
gutter={8}
class="am-tab-tooltip"
openDelay={0}
>
<span class="am-tab-title">
<span class="am-tab-icon">
<Icon name="console" size="small" />
</span>
<span class="am-tab-label">{props.label}</span>
</span>
<span class="am-tab-label">{props.label}</span>
</span>
</TooltipKeybind>
</TooltipKeybind>
</div>
<TooltipKeybind
title={t("agentManager.tab.close")}
keybind={props.closeKeybind ?? ""}
@@ -78,7 +74,8 @@ export const SortableTerminalTab: Component<{
icon="close-small"
size="small"
variant="ghost"
label={t("agentManager.tab.closeTab")}
aria-label={t("agentManager.tab.closeTab")}
tabIndex={props.active ? 0 : -1}
class="am-tab-close"
onClick={props.onClose}
/>
@@ -107,6 +104,6 @@ export const SortableTerminalTab: Component<{
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
</div>
</SortableTabContainer>
)
}
@@ -23,6 +23,10 @@ export interface TerminalTabRenderDeps {
onMiddleClick: (id: string, e: MouseEvent) => void
onClose: (id: string) => void
onCloseOthers: (id: string) => void
role?: "tab"
selected?: boolean
tabIndex?: number
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
}
/** Render the terminal entry inside the agent-manager tab bar `<For>`. */
@@ -38,6 +42,10 @@ export function renderTerminalTab(deps: TerminalTabRenderDeps): JSX.Element {
keybind={isActive() ? "" : deps.keybind()}
closeKeybind={deps.closeKeybind()}
active={isActive()}
role={deps.role}
selected={deps.selected}
tabIndex={deps.tabIndex}
onKeyDown={deps.onKeyDown}
onSelect={() => deps.onSelect(deps.id)}
onMiddleClick={(e: MouseEvent) => deps.onMiddleClick(deps.id, e)}
onClose={(e: MouseEvent) => {
+3 -2
View File
@@ -281,9 +281,10 @@ const AppContent: Component = () => {
if (agent) session.selectAgent(agent.name)
}
const handleForked = (message: { type?: string; sessionID?: string }) => {
const handleForked = (message: { type?: string; sessionID?: string; forkedFromID?: string }) => {
if (message.type !== "sessionForked" || !message.sessionID) return
if (tabs) tabs.open(message.sessionID)
if (tabs && message.forkedFromID) tabs.openAfter(message.forkedFromID, message.sessionID)
if (tabs && !message.forkedFromID) tabs.open(message.sessionID)
if (!tabs) session.selectSession(message.sessionID)
setCurrentView("newTask")
}
@@ -349,6 +349,7 @@ export const ChatView: Component<ChatViewProps> = (props) => {
readonly={props.readonly}
emptyState={props.emptyState}
announce={isSidebar()}
sessionID={pendingSessionID}
/>
}
>
@@ -1,4 +1,4 @@
import { Component, Show, createEffect, createMemo, createSignal } from "solid-js"
import { Component, Show, createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { useNotifications } from "../../context/notifications"
import { useVSCode } from "../../context/vscode"
import { useSession } from "../../context/session"
@@ -8,13 +8,14 @@ import { KILO_PROVIDER_ID } from "../../../../src/shared/provider-model"
import { TelemetryEventName } from "../../../../src/services/telemetry/types"
import { stripSubProviderPrefix } from "../shared/model-selector-utils"
export const KiloNotifications: Component = () => {
export const KiloNotifications: Component<{ sessionID?: Accessor<string | undefined> }> = (props) => {
const { filteredNotifications, dismiss } = useNotifications()
const vscode = useVSCode()
const session = useSession()
const provider = useProvider()
const language = useLanguage()
const [index, setIndex] = createSignal(0)
const sessionID = () => props.sessionID?.() ?? session.currentSessionID() ?? session.draftSessionID()
const items = filteredNotifications
const total = () => items().length
@@ -57,7 +58,7 @@ export const KiloNotifications: Component = () => {
const canSwitchModel = createMemo(() => {
const suggestion = suggestedModel()
if (!suggestion) return false
const sel = session.selected()
const sel = session.selected(sessionID())
if (sel && sel.providerID === suggestion.providerID && sel.modelID === suggestion.modelID) return false
return true
})
@@ -77,7 +78,7 @@ export const KiloNotifications: Component = () => {
const handleTryModel = () => {
const suggestion = suggestedModel()
if (!suggestion) return
session.selectModel(suggestion.providerID, suggestion.modelID)
session.selectModel(suggestion.providerID, suggestion.modelID, sessionID())
vscode.postMessage({
type: "telemetry",
event: TelemetryEventName.NOTIFICATION_CLICKED,
@@ -9,7 +9,18 @@
* Shows recent sessions in the empty state for quick resumption.
*/
import { type Component, type JSX, For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import {
type Accessor,
type Component,
type JSX,
For,
Show,
createEffect,
createMemo,
createSignal,
on,
onCleanup,
} from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { createAutoScroll } from "@kilocode/kilo-ui/hooks"
@@ -65,6 +76,7 @@ interface MessageListProps {
emptyState?: () => JSX.Element
/** Announce transcript changes as a live log. Disable for multi-session surfaces with concurrent streams. */
announce?: boolean
sessionID?: Accessor<string | undefined>
}
export const MessageList: Component<MessageListProps> = (props) => {
@@ -310,7 +322,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
<Show when={isEmpty()}>
<div class="welcome-header">
<AccountSwitcher class="account-switcher-welcome" />
<KiloNotifications />
<KiloNotifications sessionID={props.sessionID} />
</div>
</Show>
<div
@@ -58,14 +58,25 @@ import {
scopeDraftKey,
sessionDraftKey,
} from "../../utils/prompt-drafts"
import { drafts, imageDrafts, reviewDrafts } from "../../utils/draft-store"
import {
beginPendingSend,
clearPendingDraftDiscarded,
clearSessionDraftDiscarded,
drafts,
finishPendingSend,
imageDrafts,
isPendingDraftDiscarded,
isSessionDraftDiscarded,
reviewDrafts,
savePromptDraft,
scrollDrafts,
} from "../../utils/draft-store"
import { ReviewComments } from "./ReviewComments"
import { partReview, reviewBody } from "../../../../src/shared/review-comments"
import { isEnterKeyCommitNotIme } from "../../utils/ime-enter"
import { MEMORY_USAGE, parseMemoryCommand } from "../../utils/memory-command"
import { useMemory } from "../../context/memory"
const scrolls = new Map<string, number>()
function mergeReviewComments(current: ReviewComment[], incoming: ReviewComment[]): ReviewComment[] {
if (incoming.length === 0) return current
const map = new Map(current.map((item) => [item.id, item]))
@@ -75,6 +86,18 @@ function mergeReviewComments(current: ReviewComment[], incoming: ReviewComment[]
return [...map.values()]
}
function finishPending(id: string | undefined): boolean {
if (!id) return false
finishPendingSend(id)
if (!isPendingDraftDiscarded(id)) return false
clearPendingDraftDiscarded(id)
return true
}
function beginPending(id: string | undefined) {
if (id) beginPendingSend(id)
}
interface PromptInputProps {
blocked?: () => boolean
blockedReason?: () => string | undefined
@@ -145,22 +168,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
next: string,
comments: ReviewComment[],
imgs: ImageAttachment[],
scroll = textareaRef?.scrollTop ?? scrolls.get(key) ?? 0,
) => {
if (next) drafts.set(key, next)
else drafts.delete(key)
if (comments.length > 0) reviewDrafts.set(key, comments)
else reviewDrafts.delete(key)
if (imgs.length > 0) imageDrafts.set(key, imgs)
else imageDrafts.delete(key)
if (next || comments.length > 0 || imgs.length > 0) scrolls.set(key, scroll)
else scrolls.delete(key)
}
scroll = textareaRef?.scrollTop ?? scrollDrafts.get(key) ?? 0,
) => savePromptDraft(key, next, comments, imgs, scroll)
const readDraft = () => ({
text: text().trim(),
comments: reviewComments(),
images: imageAttach.images(),
scroll: textareaRef?.scrollTop ?? scrolls.get(draftKey()) ?? 0,
scroll: textareaRef?.scrollTop ?? scrollDrafts.get(draftKey()) ?? 0,
})
const [text, setText] = createSignal("")
@@ -301,7 +315,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const draft = drafts.get(key) ?? ""
const pending = reviewDrafts.get(key) ?? []
const scroll = scrolls.get(key) ?? 0
const scroll = scrollDrafts.get(key) ?? 0
setText(draft)
setReviewComments(pending)
imageAttach.replace(imageDrafts.get(key) ?? [])
@@ -475,38 +489,41 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const restoreFailed = (failed: SendMessageFailedMessage) => {
// Only restore a failed draft when the user has not started another one.
if (text().trim() || reviewComments().length > 0 || imageAttach.images().length > 0) return
// If the user explicitly transitioned out of the original send's scope
// (clearCurrentSession() or Delete on the current/draft session), don't
// restore anywhere. This covers BOTH the obvious "user clicked New Task
// and we land in :new" case AND the tighter race window where the user
// clicked Delete on the current session: the backend's sessionDeleted
// round-trip hasn't completed yet so currentSessionID/draftSessionID
// still point at the dead session, but userClearedSession is true. Without
// this guard, the session-scoped candidate on the previous lines would
// match the still-current draftKey and rehydrate the failed draft into
// the session the user explicitly chose to delete.
if (session.userClearedSession()) return
// Build candidates from the keys the original send was actually scoped
// under. :new is only added when the user has effectively returned to the
// empty state — i.e. no current session and no pending draft. Combined
// with the userClearedSession early return above, this catches both
// "send from session -> session deleted mid-round-trip" and "send from
// :new (mints draftID) -> session created mid-round-trip -> session
// deleted externally" without rehydrating into any user-explicit clear.
const candidates = new Set<string>()
if (failed.sessionID) candidates.add(scopeDraftKey(boxKey(), sessionDraftKey(failed.sessionID)))
if (failed.draftID) candidates.add(scopeDraftKey(boxKey(), pendingDraftKey(failed.draftID)))
if (!session.currentSessionID() && !session.draftSessionID()) candidates.add(scopeDraftKey(boxKey(), "new"))
const target = draftKey()
if (!candidates.has(target)) return
const draft = failed.review ? reviewBody(failed.review, failed.text) : failed.text
if (draft === undefined) return
if (failed.review) replaceReviewComments(failed.review.comments)
if (
(failed.draftID && isPendingDraftDiscarded(failed.draftID)) ||
(failed.sessionID && isSessionDraftDiscarded(failed.sessionID))
) {
if (failed.draftID) clearPendingDraftDiscarded(failed.draftID)
if (failed.sessionID) clearSessionDraftDiscarded(failed.sessionID)
return
}
if (failed.sessionID && !session.sessions().some((item) => item.id === failed.sessionID)) return
const target = failed.sessionID
? scopeDraftKey(boxKey(), sessionDraftKey(failed.sessionID))
: failed.draftID
? scopeDraftKey(boxKey(), pendingDraftKey(failed.draftID))
: !session.currentSessionID() && !session.draftSessionID() && !session.userClearedSession()
? scopeDraftKey(boxKey(), "new")
: undefined
if (!target) return
const comments = failed.review?.comments ?? []
const images = (failed.files ?? [])
.filter((file) => file.mime.startsWith("image/") && file.url.startsWith("data:"))
.map((file) => ({
id: crypto.randomUUID(),
filename: file.filename ?? "image",
mime: file.mime,
dataUrl: file.url,
}))
if (target !== draftKey()) {
saveDraft(target, draft, comments, images, scrollDrafts.get(target) ?? 0)
return
}
// Do not overwrite a new draft the user started while the send was in flight.
if (text().trim() || reviewComments().length > 0 || imageAttach.images().length > 0) return
replaceReviewComments(comments)
if (draft) {
setText(draft)
mention.seedFromText(draft)
@@ -516,14 +533,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
textareaRef.focus()
}
}
const images = (failed.files ?? [])
.filter((file) => file.mime.startsWith("image/") && file.url.startsWith("data:"))
.map((file) => ({
id: crypto.randomUUID(),
filename: file.filename ?? "image",
mime: file.mime,
dataUrl: file.url,
}))
if (images.length === 0) return
imageAttach.replace(images)
imageDrafts.set(target, images)
@@ -668,7 +677,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const source = scopeDraftKey(boxKey(), raw)
const target = scopeDraftKey(boxKey(), sessionDraftKey(message.session.id))
if (source === draftKey()) saveDraft(source, text(), reviewComments(), imageAttach.images())
movePromptDraft({ text: drafts, comments: reviewDrafts, images: imageDrafts, scrolls }, source, target)
movePromptDraft(
{ text: drafts, comments: reviewDrafts, images: imageDrafts, scrolls: scrollDrafts },
source,
target,
)
}
if (
message.draftID &&
@@ -746,7 +759,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const syncHighlightScroll = () => {
if (!textareaRef) return
scrolls.set(draftKey(), textareaRef.scrollTop)
scrollDrafts.set(draftKey(), textareaRef.scrollTop)
if (highlightRef) highlightRef.scrollTop = textareaRef.scrollTop
}
@@ -994,7 +1007,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
drafts.delete(draftKey())
reviewDrafts.delete(draftKey())
imageDrafts.delete(draftKey())
scrolls.delete(draftKey())
scrollDrafts.delete(draftKey())
if (textareaRef) textareaRef.style.height = "auto"
return
}
@@ -1019,7 +1032,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
drafts.delete(draftKey())
reviewDrafts.delete(draftKey())
imageDrafts.delete(draftKey())
scrolls.delete(draftKey())
scrollDrafts.delete(draftKey())
if (textareaRef) textareaRef.style.height = "auto"
matched.action()
return
@@ -1042,8 +1055,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const mentionFiles = mention.parseFileAttachments(draft)
const imgFiles = imgs.map((img) => ({ mime: img.mime, url: img.dataUrl, filename: img.filename }))
const pendingId = props.pendingSessionID ?? session.draftSessionID()
const id = sid()
const origin = session.currentSessionID()
const pendingId = props.pendingSessionID ?? (!origin ? session.draftSessionID() : undefined)
const id = origin ?? pendingId
beginPending(pendingId)
const sel = session.selected(id)
const context = ctx()
const key = draftKey()
@@ -1052,14 +1067,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
showToast({ variant: "error", title: "Terminal context unavailable", description: err.message })
return undefined
})
if (hasTerminalMention(message) && !terminalFile) return
if (hasTerminalMention(message) && !terminalFile) {
finishPending(pendingId)
return
}
const gitFile = await git.resolveAttachment(message, id).catch((err: Error) => {
const gitFile = await git.resolveAttachment(message, id, context).catch((err: Error) => {
showToast({ variant: "error", title: "Git changes unavailable", description: err.message })
return undefined
})
if (hasGit() && hasGitChangesMention(message) && !gitFile) return
if (isDisabled()) return
if (hasGit() && hasGitChangesMention(message) && !gitFile) {
finishPending(pendingId)
return
}
if (isDisabled()) {
finishPending(pendingId)
return
}
if (finishPending(pendingId)) return
const allFiles = [
...mentionFiles,
@@ -1072,15 +1097,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
// Server-side slash command (cmdMatch/matched already computed above)
if (matched && !data) {
const args = draft.slice(cmdMatch![0].length).trim()
session.sendCommand(matched.name, args, sel?.providerID, sel?.modelID, attachments, pendingId, context)
session.sendCommand(
matched.name,
args,
sel?.providerID,
sel?.modelID,
attachments,
pendingId,
context,
origin ?? null,
)
} else {
session.sendMessage(message, sel?.providerID, sel?.modelID, attachments, pendingId, context, data)
session.sendMessage(message, sel?.providerID, sel?.modelID, attachments, pendingId, context, data, origin ?? null)
}
drafts.delete(key)
reviewDrafts.delete(key)
imageDrafts.delete(key)
scrolls.delete(key)
scrollDrafts.delete(key)
if (draftKey() !== key) return
history.append(draft)
@@ -15,37 +15,41 @@ export const SessionTab: Component<{
selected?: boolean
tabIndex?: number
closeTabIndex?: number
keyShortcuts?: string
onSelect: () => void
onMiddleClick?: (event: MouseEvent) => void
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
onClose: () => void
}> = (props) => (
<div
class={`am-tab ${props.active ? "am-tab-active" : ""}`}
role={props.role}
aria-selected={props.selected}
tabIndex={props.tabIndex}
onClick={props.onSelect}
onMouseDown={props.onMiddleClick}
onKeyDown={props.onKeyDown}
>
<TooltipKeybind
title={props.title}
keybind={props.keybind ?? ""}
placement="bottom"
gutter={8}
class="am-tab-tooltip"
openDelay={0}
<div class={`am-tab ${props.active ? "am-tab-active" : ""}`}>
<div
class="am-tab-target"
role={props.role}
aria-selected={props.selected}
aria-keyshortcuts={props.keyShortcuts}
tabIndex={props.tabIndex}
onClick={props.onSelect}
onMouseDown={props.onMiddleClick}
onKeyDown={props.onKeyDown}
>
<span class="am-tab-title">
<Show when={props.busy}>
<span class="am-tab-icon">
<Spinner class="am-worktree-spinner" />
</span>
</Show>
<span class="am-tab-label">{props.title}</span>
</span>
</TooltipKeybind>
<TooltipKeybind
title={props.title}
keybind={props.keybind ?? ""}
placement="bottom"
gutter={8}
class="am-tab-tooltip"
openDelay={0}
>
<span class="am-tab-title">
<Show when={props.busy}>
<span class="am-tab-icon">
<Spinner class="am-worktree-spinner" />
</span>
</Show>
<span class="am-tab-label">{props.title}</span>
</span>
</TooltipKeybind>
</div>
<TooltipKeybind
title={props.closeTitle}
keybind={props.closeKeybind ?? ""}
@@ -0,0 +1,43 @@
import { ContextMenu } from "@kilocode/kilo-ui/context-menu"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Show, type JSX, type ParentComponent } from "solid-js"
import { useLanguage } from "../../context/language"
export const SessionTabMenu: ParentComponent<{
showFork?: boolean
onFork?: () => void
onClose: () => void
onCloseOthers?: () => void
closeShortcut?: JSX.Element
}> = (props) => {
const { t } = useLanguage()
return (
<ContextMenu>
<ContextMenu.Trigger as="div" style={{ display: "contents" }}>
{props.children}
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content class="session-tab-menu am-ctx-menu">
<Show when={props.showFork}>
<ContextMenu.Item disabled={!props.onFork} onSelect={() => props.onFork?.()}>
<Icon name="fork" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.tab.forkSession")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Separator />
</Show>
<ContextMenu.Item onSelect={props.onClose}>
<Icon name="close" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.tab.close")}</ContextMenu.ItemLabel>
{props.closeShortcut}
</ContextMenu.Item>
<Show when={props.onCloseOthers}>
<ContextMenu.Item onSelect={() => props.onCloseOthers?.()}>
<Icon name="close" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.tab.closeOthers")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</Show>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
)
}
@@ -1,15 +1,25 @@
import { For, createMemo, type Component, type JSX } from "solid-js"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { For, Show, createMemo, createSignal, type Component, type JSX } from "solid-js"
import { useLanguage } from "../../context/language"
import { useLocalTabs } from "../../context/local-tabs"
import { useSession } from "../../context/session"
import { isPendingTab } from "../../utils/local-tabs"
import { useTabScroll } from "../../utils/tab-scroll"
import { focusPrompt, focusSelectedTab, focusTabElement, handleTabKey } from "../../utils/tab-navigation"
import { setTabWidths } from "../../utils/tab-widths"
import { useVSCode } from "../../context/vscode"
import { SessionTab } from "./SessionTab"
import { SessionTabMenu } from "./SessionTabMenu"
import { ConstrainDragYAxis, SortableTabContainer } from "./TabDnd"
export const SessionTabStrip: Component = () => {
const tabs = useLocalTabs()
const session = useSession()
const language = useLanguage()
const vscode = useVSCode()
const [dragging, setDragging] = createSignal<string>()
const [announcement, setAnnouncement] = createSignal("")
if (!tabs) return null
const items = createMemo(() => new Map(session.sessions().map((item) => [item.id, item])))
@@ -25,73 +35,130 @@ export const SessionTabStrip: Component = () => {
if (event.button !== 1) return
event.preventDefault()
event.stopPropagation()
tabs.close(id)
}
const focus = (root: Element | null, id: string) => {
requestAnimationFrame(() => {
const el = root?.querySelector(`[data-tab-id="${id}"] .am-tab`)
if (el instanceof HTMLElement) el.focus()
})
close(id)
}
const key = (id: string, event: KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
const root = event.currentTarget instanceof HTMLElement ? event.currentTarget.closest(".am-tab-list") : null
if (
(event.metaKey || event.ctrlKey) &&
event.shiftKey &&
(event.key === "ArrowLeft" || event.key === "ArrowRight")
) {
event.preventDefault()
tabs.select(id)
const ids = tabs.ids()
const target = tabs.move(id, event.key === "ArrowLeft" ? -1 : 1)
if (target === undefined) return
tabs.persist()
setAnnouncement(`${title(id)} ${target + 1}/${ids.length}`)
focusTabElement(root, id)
return
}
const ids = tabs.ids()
const index = ids.indexOf(id)
const next = (() => {
if (event.key === "ArrowLeft") return ids[(index - 1 + ids.length) % ids.length]
if (event.key === "ArrowRight") return ids[(index + 1) % ids.length]
if (event.key === "Home") return ids[0]
if (event.key === "End") return ids[ids.length - 1]
return undefined
})()
if (!next) return
event.preventDefault()
tabs.select(next)
const root = event.currentTarget instanceof HTMLElement ? event.currentTarget.closest(".am-tab-list") : null
focus(root, next)
handleTabKey({ ids: tabs.ids(), id, event, select: tabs.select, root })
}
const scroll = useTabScroll(tabs.ids, tabs.active)
const root = () => document.querySelector("[data-component=session-tabs] .am-tab-list")
const freeze = () => setTabWidths(true, document)
const release = () => setTabWidths(false, document)
const close = (id: string) => {
freeze()
tabs.close(id)
focusSelectedTab(document, focusPrompt)
requestAnimationFrame(release)
}
const closeOthers = (id: string) => {
freeze()
tabs.closeOthers(id)
focusTabElement(document, id, focusPrompt)
requestAnimationFrame(release)
}
const dragStart = (event: DragEvent) => {
const id = event.draggable?.id
if (typeof id !== "string") return
freeze()
setDragging(id)
}
const dragOver = (event: DragEvent) => {
const from = event.draggable?.id
const to = event.droppable?.id
if (typeof from === "string" && typeof to === "string") tabs.reorder(from, to)
}
const dragEnd = () => {
setDragging(undefined)
release()
tabs.persist()
}
return (
<div data-component="session-tabs" class="am-tab-bar session-tab-bar">
<div class="am-tab-scroll-area">
<div class={`am-tab-fade am-tab-fade-left ${scroll.showLeft() ? "am-tab-fade-visible" : ""}`} />
<div class="am-tab-list-wrap">
<div
class="am-tab-list"
ref={scroll.setRef}
role="tablist"
style={{ "--tab-count": `${tabs.ids().length}` } as JSX.CSSProperties}
>
<For each={tabs.ids()}>
{(id) => (
<div class="am-tab-sortable" data-tab-id={id}>
<SessionTab
title={title(id)}
active={tabs.active() === id}
busy={working(id)}
closeTitle={language.t("common.closeTab")}
closeLabel={language.t("common.closeTab")}
role="tab"
selected={tabs.active() === id}
tabIndex={tabs.active() === id ? 0 : -1}
closeTabIndex={tabs.active() === id ? 0 : -1}
onSelect={() => tabs.select(id)}
onMiddleClick={(event) => middle(id, event)}
onKeyDown={(event) => key(id, event)}
onClose={() => tabs.close(id)}
/>
</div>
)}
</For>
<DragDropProvider
collisionDetector={closestCenter}
onDragStart={dragStart}
onDragOver={dragOver}
onDragEnd={dragEnd}
>
<DragDropSensors />
<ConstrainDragYAxis />
<div
data-component="session-tabs"
class="am-tab-bar session-tab-bar"
onPointerLeave={() => {
if (!dragging()) release()
}}
>
<div class="am-tab-scroll-area">
<div class={`am-tab-fade am-tab-fade-left ${scroll.showLeft() ? "am-tab-fade-visible" : ""}`} />
<div class="am-tab-list-wrap">
<div
class="am-tab-list"
ref={scroll.setRef}
role="tablist"
style={{ "--tab-count": `${tabs.ids().length}` } as JSX.CSSProperties}
>
<SortableProvider ids={tabs.ids()}>
<For each={tabs.ids()}>
{(id) => (
<SortableTabContainer id={id}>
<SessionTabMenu
showFork
onFork={
!isPendingTab(id) && !working(id)
? () => vscode.postMessage({ type: "forkSession", sessionId: id })
: undefined
}
onClose={() => close(id)}
onCloseOthers={tabs.ids().length > 1 ? () => closeOthers(id) : undefined}
>
<SessionTab
title={title(id)}
active={tabs.active() === id}
busy={working(id)}
closeTitle={language.t("common.closeTab")}
closeLabel={language.t("common.closeTab")}
role="tab"
selected={tabs.active() === id}
tabIndex={tabs.active() === id ? 0 : -1}
closeTabIndex={tabs.active() === id ? 0 : -1}
keyShortcuts="Meta+Shift+ArrowLeft Control+Shift+ArrowLeft Meta+Shift+ArrowRight Control+Shift+ArrowRight"
onSelect={() => tabs.select(id)}
onMiddleClick={(event) => middle(id, event)}
onKeyDown={(event) => key(id, event)}
onClose={() => close(id)}
/>
</SessionTabMenu>
</SortableTabContainer>
)}
</For>
</SortableProvider>
</div>
</div>
<div class={`am-tab-fade am-tab-fade-right ${scroll.showRight() ? "am-tab-fade-visible" : ""}`} />
</div>
<div class={`am-tab-fade am-tab-fade-right ${scroll.showRight() ? "am-tab-fade-visible" : ""}`} />
</div>
</div>
<div class="sr-only" aria-live="polite">
{announcement()}
</div>
<DragOverlay>
<Show when={dragging()}>{(id) => <div class="session-tab-overlay">{title(id())}</div>}</Show>
</DragOverlay>
</DragDropProvider>
)
}
@@ -0,0 +1,43 @@
declare module "solid-js" {
namespace JSX {
interface Directives {
sortable: true
}
}
}
import { createSortable, useDragDropContext, type Transformer } from "@thisbeyond/solid-dnd"
import { createRoot, onCleanup, type Component, type ParentComponent } from "solid-js"
export const ConstrainDragYAxis: Component = () => {
const context = useDragDropContext()
if (!context) return null
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
const transformer: Transformer = { id: "constrain-y-axis", order: 100, callback: (value) => ({ ...value, y: 0 }) }
const dispose = createRoot((cleanup) => {
onDragStart(({ draggable }) => {
if (draggable) addTransformer("draggables", draggable.id as string, transformer)
})
onDragEnd(({ draggable }) => {
if (draggable) removeTransformer("draggables", draggable.id as string, transformer.id)
})
return cleanup
})
onCleanup(dispose)
return null
}
export const SortableTabContainer: ParentComponent<{ id: string }> = (props) => {
const sortable = createSortable(props.id)
void sortable
return (
<div
use:sortable
class="am-tab-sortable"
classList={{ "am-tab-dragging": sortable.isActiveDraggable }}
data-tab-id={props.id}
>
{props.children}
</div>
)
}
@@ -9,6 +9,7 @@ import { Button } from "@kilocode/kilo-ui/button"
import { useDialog } from "@kilocode/kilo-ui/context/dialog"
import { useLanguage } from "../../context/language"
import { useSession } from "../../context/session"
import { useLocalTabs } from "../../context/local-tabs"
import { CloudImportDialog } from "../chat/CloudImportDialog"
import SessionList from "./SessionList"
import CloudSessionList from "./CloudSessionList"
@@ -22,6 +23,7 @@ const HistoryView: Component<HistoryViewProps> = (props) => {
const language = useLanguage()
const dialog = useDialog()
const session = useSession()
const tabs = useLocalTabs()
const [tab, setTab] = createSignal<"local" | "cloud">("local")
let local: HTMLButtonElement | undefined
let cloud: HTMLButtonElement | undefined
@@ -53,6 +55,7 @@ const HistoryView: Component<HistoryViewProps> = (props) => {
}
function selectCloudSession(id: string) {
tabs?.previewCloud(id)
session.selectCloudSession(id)
props.onBack?.()
}
@@ -15,7 +15,10 @@ import { useVSCode } from "./vscode"
import {
PENDING_TAB_PREFIX,
addPendingTab,
addSessionTab,
closeOtherTabs,
closeTab,
insertSessionTabAfter,
isPendingTab,
openSessionTab,
pendingTabForCreated,
@@ -24,6 +27,13 @@ import {
restoreTabs,
type LocalTabState,
} from "../utils/local-tabs"
import {
deletePendingDraft,
discardPendingDraft,
isPendingSend,
promotePendingDraftDiscard,
} from "../utils/draft-store"
import { moveTab, reorderTabs } from "../utils/tab-order"
interface LocalTabsState extends Record<string, unknown> {
sidebarSessionTabIDs?: string[]
@@ -36,8 +46,14 @@ interface LocalTabsValue {
pending: Accessor<string | undefined>
add: () => string
open: (id: string) => void
openAfter: (source: string, id: string) => void
select: (id: string) => void
close: (id: string) => void
closeOthers: (id: string) => void
previewCloud: (id: string) => void
reorder: (from: string, to: string) => boolean
move: (id: string, offset: -1 | 1) => number | undefined
persist: () => void
}
const LocalTabsContext = createContext<LocalTabsValue>()
@@ -53,6 +69,7 @@ export const LocalTabsProvider: ParentComponent = (props) => {
const init = restoreTabs(saved?.sidebarSessionTabIDs, saved?.sidebarActiveSessionTabID, pending)
const [ids, setIds] = createSignal(init.ids)
const [active, setActive] = createSignal(init.active)
const [cloud, setCloud] = createSignal<string>()
const fresh = new Set<string>()
const current = (): LocalTabState => ({ ids: ids(), active: active() })
const apply = (next: LocalTabState) => {
@@ -60,6 +77,7 @@ export const LocalTabsProvider: ParentComponent = (props) => {
if (active() !== next.active) setActive(next.active)
}
const focus = (id: string | undefined) => {
setCloud(undefined)
if (!id || isPendingTab(id)) {
session.clearCurrentSession()
return
@@ -83,6 +101,12 @@ export const LocalTabsProvider: ParentComponent = (props) => {
focus(id)
}
const openAfter = (source: string, id: string) => {
apply(insertSessionTabAfter(current(), source, id))
focus(id)
persist()
}
const add = () => {
const id = pending()
apply(addPendingTab(current(), id))
@@ -95,6 +119,34 @@ export const LocalTabsProvider: ParentComponent = (props) => {
const next = closeTab(current(), id, pending)
apply(next)
if (before === id || before !== next.active) focus(next.active)
if (isPendingTab(id)) {
if (session.isSubmitting(id) || isPendingSend(id)) discardPendingDraft(id)
queueMicrotask(() => deletePendingDraft(id))
}
}
const closeOthers = (id: string) => {
const removed = ids().filter((tab) => tab !== id && isPendingTab(tab))
const next = closeOtherTabs(current(), id)
apply(next)
focus(next.active)
for (const pending of removed) {
if (session.isSubmitting(pending) || isPendingSend(pending)) discardPendingDraft(pending)
}
queueMicrotask(() => removed.forEach(deletePendingDraft))
}
const previewCloud = (id: string) => setCloud(id)
const reorder = (from: string, to: string) => {
const next = reorderTabs(ids(), from, to)
if (!next) return false
setIds(next)
return true
}
const move = (id: string, offset: -1 | 1) => {
const next = moveTab(ids(), id, offset)
if (!next) return undefined
setIds(next)
return next.indexOf(id)
}
let restored = false
@@ -107,15 +159,18 @@ export const LocalTabsProvider: ParentComponent = (props) => {
})
let timer: ReturnType<typeof setTimeout> | undefined
createEffect(() => {
const persist = () => {
const tabs = real()
const tab = active()
const selected = tab && !isPendingTab(tab) ? tab : undefined
const prev = vscode.getState<LocalTabsState>() ?? {}
vscode.setState({ ...prev, sidebarSessionTabIDs: tabs, sidebarActiveSessionTabID: selected })
}
createEffect(() => {
real()
active()
clearTimeout(timer)
timer = setTimeout(() => {
const prev = vscode.getState<LocalTabsState>() ?? {}
vscode.setState({ ...prev, sidebarSessionTabIDs: tabs, sidebarActiveSessionTabID: selected })
}, 300)
timer = setTimeout(persist, 300)
})
onCleanup(() => clearTimeout(timer))
@@ -125,8 +180,13 @@ export const LocalTabsProvider: ParentComponent = (props) => {
onMount(() => {
const cleanup = vscode.onMessage((message) => {
if (message.type === "openCloudSession") {
setCloud(message.sessionId)
return
}
if (message.type === "sessionCreated") {
const draft = pendingTabForCreated(ids(), activePending(), message.draftID)
if (message.draftID && promotePendingDraftDiscard(message.draftID, message.session.id)) return
const draft = pendingTabForCreated(ids(), message.draftID)
if (!draft) return
const before = active()
const next = replacePendingTab(current(), draft, message.session.id)
@@ -136,8 +196,10 @@ export const LocalTabsProvider: ParentComponent = (props) => {
return
}
if (message.type === "cloudSessionImported") {
const activate = cloud() === message.cloudSessionId
fresh.add(message.session.id)
apply(openSessionTab(current(), message.session.id))
apply(activate ? openSessionTab(current(), message.session.id) : addSessionTab(current(), message.session.id))
if (activate) setCloud(undefined)
return
}
if (message.type === "sessionsLoaded") {
@@ -161,7 +223,23 @@ export const LocalTabsProvider: ParentComponent = (props) => {
})
return (
<LocalTabsContext.Provider value={{ ids, active, pending: activePending, add, open, select, close }}>
<LocalTabsContext.Provider
value={{
ids,
active,
pending: activePending,
add,
open,
openAfter,
select,
close,
closeOthers,
previewCloud,
reorder,
move,
persist,
}}
>
{props.children}
</LocalTabsContext.Provider>
)
@@ -76,7 +76,7 @@ import { getVariant, sessionVariantKeys, transferVariants, variantKey } from "./
import { KILO_AUTO, KILO_PROVIDER_ID, parseModelString } from "../../../src/shared/provider-model"
import { reviewMetadata, type ReviewMessageData } from "../../../src/shared/review-comments"
import { visibleMessages as filterVisibleMessages } from "./session-queue"
import { deleteDraftsForSession } from "../utils/draft-store"
import { clearSessionDraftDiscarded, deleteDraftsForSession } from "../utils/draft-store"
import { createAbortState } from "./abort-state"
import { clearIfOn, createCloudPrune } from "./session-cloud-prune"
import { isSameSessionTree } from "./model-usage"
@@ -142,6 +142,7 @@ interface SessionContextValue {
statusText: Accessor<string | undefined>
busySince: Accessor<number | undefined>
submitting: Accessor<boolean>
isSubmitting: (id: string) => boolean
loading: Accessor<boolean>
loadingOlderMessages: Accessor<boolean>
hasOlderMessages: Accessor<boolean>
@@ -266,6 +267,7 @@ interface SessionContextValue {
draftID?: string,
context?: string,
review?: ReviewMessageData,
origin?: string | null,
) => void
sendCommand: (
command: string,
@@ -275,6 +277,7 @@ interface SessionContextValue {
files?: FileAttachment[],
draftID?: string,
context?: string,
origin?: string | null,
) => void
abort: () => void
compact: () => void
@@ -353,8 +356,9 @@ export const SessionProvider: ParentComponent = (props) => {
}
const submitting = () => {
const id = currentSessionID() ?? draftSessionID()
return id ? (submissionMap[id] ?? 0) > 0 : false
return id ? isSubmitting(id) : false
}
const isSubmitting = (id: string) => (submissionMap[id] ?? 0) > 0
const [loading, setLoading] = createSignal(false)
const [loaded, setLoaded] = createSignal<Set<string>>(new Set())
@@ -1342,7 +1346,7 @@ export const SessionProvider: ParentComponent = (props) => {
const active = currentSessionID()
const draft = draftSessionID()
if (!draftID || draft === draftID || active === draftID) {
if (draftID && (draft === draftID || active === draftID)) {
setCurrentSessionID(session.id)
setDraftSessionID(session.id)
setUserClearedSession(false)
@@ -1556,6 +1560,7 @@ export const SessionProvider: ParentComponent = (props) => {
}
function handleMessageCreated(message: Message) {
if (message.role === "assistant") clearSessionDraftDiscarded(message.sessionID)
// Message confirmed by server — no longer optimistic.
// Clear placeholder parts so they don't duplicate alongside real parts
// arriving via individual part.updated events (the server's message.updated
@@ -1852,7 +1857,6 @@ export const SessionProvider: ParentComponent = (props) => {
if (!message.sessionID && message.draftID) {
if (draftSessionID() !== message.draftID) agentDrafts.prune(message.draftID)
setDraftSessionID(message.draftID)
}
}
@@ -2102,6 +2106,7 @@ export const SessionProvider: ParentComponent = (props) => {
freshSessions.add(session.id)
const cloudKey = `cloud:${cloudSessionId}`
const cloudMessages = store.messages[cloudKey] ?? []
const active = cloudPreviewId() === cloudSessionId && currentSessionID() === cloudKey
batch(() => {
setLoaded((prev) => {
const next = new Set(prev)
@@ -2120,11 +2125,12 @@ export const SessionProvider: ParentComponent = (props) => {
setStore("messages", session.id, cloudMessages)
rebuildToolParts(session.id, cloudMessages)
setCloudPreviewId(null)
setCurrentSessionID(session.id)
setDraftSessionID(session.id)
setUserClearedSession(false)
if (active) {
setCloudPreviewId(null)
setCurrentSessionID(session.id)
setDraftSessionID(session.id)
setUserClearedSession(false)
}
setStore(
"sessions",
@@ -2242,6 +2248,7 @@ export const SessionProvider: ParentComponent = (props) => {
draftID?: string,
context?: string,
review?: ReviewMessageData,
origin?: string | null,
) {
if (!server.isConnected()) {
console.warn("[Kilo New] Cannot send message: not connected")
@@ -2250,9 +2257,14 @@ export const SessionProvider: ParentComponent = (props) => {
const messageID = Identifier.ascending("message")
const preview = cloudPreviewId()
const sid = origin === undefined ? currentSessionID() : (origin ?? undefined)
const preview = sid?.startsWith("cloud:")
? sid.slice("cloud:".length)
: origin === undefined
? cloudPreviewId()
: null
if (preview) {
const scope = draftID ?? currentSessionID()
const scope = draftID ?? sid
const agent = promptAgent(scope)
vscode.postMessage({
type: "importAndSend",
@@ -2269,7 +2281,6 @@ export const SessionProvider: ParentComponent = (props) => {
return
}
const sid = currentSessionID()
const suggestion = scopedSuggestions(sid)[0]
if (suggestion) dismissSuggestion(suggestion.id)
for (const q of scopedQuestions(sid)) {
@@ -2283,7 +2294,7 @@ export const SessionProvider: ParentComponent = (props) => {
clearClose(scope)
addOptimistic(scope, messageID, text, files, review)
startSubmission(scope, messageID)
if (!sid) {
if (!sid && (!draftID || draftSessionID() === scope)) {
setUserClearedSession(false)
setDraftSessionID(scope)
}
@@ -2314,6 +2325,7 @@ export const SessionProvider: ParentComponent = (props) => {
files?: FileAttachment[],
draftID?: string,
context?: string,
origin?: string | null,
) {
if (!server.isConnected()) {
console.warn("[Kilo New] Cannot send command: not connected")
@@ -2321,9 +2333,14 @@ export const SessionProvider: ParentComponent = (props) => {
}
// Cloud previews need import-then-command; post importAndSend with command metadata
const preview = cloudPreviewId()
const sid = origin === undefined ? currentSessionID() : (origin ?? undefined)
const preview = sid?.startsWith("cloud:")
? sid.slice("cloud:".length)
: origin === undefined
? cloudPreviewId()
: null
if (preview) {
const scope = draftID ?? currentSessionID()
const scope = draftID ?? sid
const agent = promptAgent(scope)
vscode.postMessage({
type: "importAndSend",
@@ -2342,7 +2359,6 @@ export const SessionProvider: ParentComponent = (props) => {
}
const messageID = Identifier.ascending("message")
const sid = currentSessionID()
const suggestion = scopedSuggestions(sid)[0]
if (suggestion) dismissSuggestion(suggestion.id)
for (const q of scopedQuestions(sid)) {
@@ -2356,7 +2372,7 @@ export const SessionProvider: ParentComponent = (props) => {
clearClose(scope)
addOptimistic(scope, messageID, `/${command} ${args}`.trim(), files)
startSubmission(scope, messageID)
if (!sid) {
if (!sid && (!draftID || draftSessionID() === scope)) {
setUserClearedSession(false)
setDraftSessionID(scope)
}
@@ -2578,6 +2594,7 @@ export const SessionProvider: ParentComponent = (props) => {
// froze the chat on the previous session while the side diff (resolved from
// the worktree selection) still moved (the reported "only the diff changes").
agentDrafts.prune(draftSessionID())
setCloudPreviewId(null)
setCurrentSessionID(id)
setDraftSessionID(id)
setUserClearedSession(false)
@@ -2882,6 +2899,7 @@ export const SessionProvider: ParentComponent = (props) => {
statusText,
busySince,
submitting,
isSubmitting,
loading,
loadingOlderMessages,
hasOlderMessages,
@@ -18,7 +18,7 @@ interface VSCodeContext {
export interface GitChangesContext {
pending: Accessor<boolean>
resolveAttachment: (text: string, sessionID?: string) => Promise<FileAttachment | undefined>
resolveAttachment: (text: string, sessionID?: string, context?: string) => Promise<FileAttachment | undefined>
}
export function useGitChangesContext(
@@ -61,7 +61,7 @@ export function useGitChangesContext(
setPending(false)
})
const request = (sessionID?: string) =>
const request = (sessionID?: string, scope?: string) =>
new Promise<string>((resolve, reject) => {
counter++
const requestId = `git-changes-context-${counter}`
@@ -71,14 +71,19 @@ export function useGitChangesContext(
requests.set(requestId, { resolve, reject, timer })
setPending(true)
vscode.postMessage({ type: "requestGitChangesContext", requestId, sessionID, agentManagerContext: context?.() })
vscode.postMessage({
type: "requestGitChangesContext",
requestId,
sessionID,
agentManagerContext: scope ?? context?.(),
})
})
const resolveAttachment = async (text: string, sessionID?: string) => {
const resolveAttachment = async (text: string, sessionID?: string, scope?: string) => {
if (!hasGitChangesMention(text)) return undefined
if (git?.() === false) return undefined
const content = await request(sessionID)
const content = await request(sessionID, scope)
return buildGitChangesAttachment(text, content)
}
@@ -81,6 +81,7 @@
max-width: var(--am-tab-width);
height: 100%;
flex: 0 0 var(--am-tab-width);
touch-action: none;
transition:
flex-basis 140ms ease,
max-width 140ms ease,
@@ -88,6 +89,29 @@
width 140ms ease;
}
.session-tab-bar .am-tab-list[data-tab-widths-frozen] .am-tab-sortable,
.session-tab-bar .am-tab-sortable.am-tab-dragging {
transition: none;
}
.session-tab-bar .am-tab-sortable.am-tab-dragging {
opacity: 0.25;
}
.session-tab-overlay {
max-width: 240px;
padding: 8px 12px;
overflow: hidden;
border: 1px solid var(--border-weak-base);
border-radius: var(--radius-sm);
background: var(--surface-raised-base);
box-shadow: var(--shadow-md);
color: var(--text-base);
font-size: var(--kilo-font-size-12);
text-overflow: ellipsis;
white-space: nowrap;
}
.session-tab-bar .am-tab {
position: relative;
display: flex;
@@ -123,6 +147,19 @@
background: color-mix(in srgb, var(--surface-interactive-base) 10%, transparent);
}
.session-tab-bar .am-tab-target {
display: flex;
align-items: center;
min-width: 0;
height: 100%;
flex: 1;
}
.session-tab-bar .am-tab:has(.am-tab-target:focus-visible) {
background: var(--button-ghost-hover, var(--surface-base-hover, rgba(128, 128, 128, 0.2)));
color: var(--text-base);
}
.session-tab-bar .am-tab-title {
display: flex;
align-items: center;
@@ -159,8 +196,6 @@
min-width: 0;
height: 100%;
flex: 1;
padding-right: 27px;
margin-right: -27px;
}
.session-tab-bar .am-tab-close-wrap {
@@ -192,3 +227,28 @@
.session-tab-bar .am-tab-close[data-component="icon-button"] [data-slot="icon-svg"] {
color: var(--text-base);
}
/* Match tab context menus to dropdown-menu visuals on every tab surface. */
.session-tab-menu {
min-width: 210px;
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent) !important;
box-shadow: var(--shadow-md) !important;
[data-slot="context-menu-item"],
[data-slot="context-menu-checkbox-item"],
[data-slot="context-menu-radio-item"],
[data-slot="context-menu-sub-trigger"] {
padding: 6px 10px;
font-size: var(--font-size-small);
transition: none;
&:hover,
&[data-highlighted] {
background: var(--surface-raised-base-hover);
}
}
[data-slot="context-menu-separator"] {
margin: 4px 0;
}
}
@@ -168,6 +168,7 @@ export interface SessionCreatedMessage {
export interface SessionForkedMessage {
type: "sessionForked"
sessionID: string
forkedFromID: string
}
export interface SessionUpdatedMessage {
@@ -1,21 +1,90 @@
import type { ReviewComment } from "../types/messages"
import type { ImageAttachment } from "../hooks/useImageAttachments"
import { pendingDraftKey, sessionDraftKey } from "./prompt-drafts"
export const drafts = new Map<string, string>()
export const reviewDrafts = new Map<string, ReviewComment[]>()
export const imageDrafts = new Map<string, ImageAttachment[]>()
export const scrollDrafts = new Map<string, number>()
const discarded = new Set<string>()
const discardedSessions = new Set<string>()
const sending = new Set<string>()
export function deleteDraftsForSession(id: string) {
if (!id) return
const sessionSuffix = `:session:${id}`
const pendingSuffix = `:pending:${id}`
const maps = [drafts, reviewDrafts, imageDrafts]
for (const map of maps) {
export function savePromptDraft(
key: string,
text: string,
comments: ReviewComment[],
images: ImageAttachment[],
scroll = 0,
) {
if (text) drafts.set(key, text)
else drafts.delete(key)
if (comments.length > 0) reviewDrafts.set(key, comments)
else reviewDrafts.delete(key)
if (images.length > 0) imageDrafts.set(key, images)
else imageDrafts.delete(key)
if (text || comments.length > 0 || images.length > 0) scrollDrafts.set(key, scroll)
else scrollDrafts.delete(key)
}
function remove(raw: string | undefined) {
if (!raw) return
const suffix = `:${raw}`
for (const map of [drafts, reviewDrafts, imageDrafts, scrollDrafts]) {
for (const key of map.keys()) {
if (typeof key !== "string") continue
if (key.endsWith(sessionSuffix) || key.endsWith(pendingSuffix)) {
map.delete(key)
}
if (typeof key === "string" && key.endsWith(suffix)) map.delete(key)
}
}
}
export function deleteDraftsForSession(id: string) {
if (!id) return
remove(sessionDraftKey(id))
remove(pendingDraftKey(id))
discardedSessions.delete(id)
}
export function discardPendingDraft(id: string) {
const key = pendingDraftKey(id)
if (!key) return
remove(key)
discarded.add(id)
}
export function deletePendingDraft(id: string) {
remove(pendingDraftKey(id))
}
export function isPendingDraftDiscarded(id: string): boolean {
return discarded.has(id)
}
export function clearPendingDraftDiscarded(id: string) {
discarded.delete(id)
}
export function promotePendingDraftDiscard(id: string, sessionID: string): boolean {
if (!discarded.delete(id)) return false
discardedSessions.add(sessionID)
return true
}
export function isSessionDraftDiscarded(id: string): boolean {
return discardedSessions.has(id)
}
export function clearSessionDraftDiscarded(id: string) {
discardedSessions.delete(id)
}
export function beginPendingSend(id: string) {
sending.add(id)
}
export function finishPendingSend(id: string) {
sending.delete(id)
}
export function isPendingSend(id: string): boolean {
return sending.has(id)
}
@@ -20,8 +20,7 @@ export interface LocalTabReconcileResult {
export const isPendingTab = (id: string) => id.startsWith(PENDING_TAB_PREFIX)
export const showTabStrip = (ids: readonly string[], check: PendingTabCheck = isPendingTab) =>
ids.length > 1 || ids.some((id) => !check(id))
export const showTabStrip = (ids: readonly string[]) => ids.length > 1
const unique = (ids: string[]) => [...new Set(ids.filter(Boolean))]
@@ -57,6 +56,15 @@ export function openSessionTab(state: LocalTabState, id: string): LocalTabState
return { ids: unique([...state.ids, id]), active: id }
}
export function insertSessionTabAfter(state: LocalTabState, source: string, id: string): LocalTabState {
if (state.ids.includes(id)) return { ids: state.ids, active: id }
const index = state.ids.indexOf(source)
if (index === -1) return openSessionTab(state, id)
const ids = [...state.ids]
ids.splice(index + 1, 0, id)
return { ids, active: id }
}
export function replacePendingTab(state: LocalTabState, pending: string, id: string): LocalTabState {
if (!state.ids.includes(pending)) return state
const ids = unique(state.ids.map((tab) => (tab === pending ? id : tab)))
@@ -66,12 +74,11 @@ export function replacePendingTab(state: LocalTabState, pending: string, id: str
export function pendingTabForCreated(
ids: readonly string[],
active: string | undefined,
draft: string | undefined,
check: PendingTabCheck = isPendingTab,
): string | undefined {
if (draft) return ids.includes(draft) && check(draft) ? draft : undefined
return active && ids.includes(active) && check(active) ? active : undefined
if (!draft) return undefined
return ids.includes(draft) && check(draft) ? draft : undefined
}
export function nextTabAfterClose(ids: readonly string[], id: string): string | undefined {
@@ -88,6 +95,15 @@ export function closeTab(state: LocalTabState, id: string, pending: PendingTabFa
return normalize(ids, nextTabAfterClose(state.ids, id), pending)
}
export function closeOtherTabs(state: LocalTabState, id: string): LocalTabState {
if (!state.ids.includes(id)) return state
return { ids: [id], active: id }
}
export function addSessionTab(state: LocalTabState, id: string): LocalTabState {
return { ids: unique([...state.ids, id]), active: state.active }
}
export function reconcileTabs(
state: LocalTabState,
loaded: string[],
@@ -0,0 +1,78 @@
export function tabForKey(ids: readonly string[], id: string, key: string): string | undefined {
const index = ids.indexOf(id)
if (index === -1 || ids.length === 0) return undefined
if (key === "ArrowLeft") return ids[(index - 1 + ids.length) % ids.length]
if (key === "ArrowRight") return ids[(index + 1) % ids.length]
if (key === "Home") return ids[0]
if (key === "End") return ids[ids.length - 1]
return undefined
}
export function focusTabElement(root: ParentNode | null, id: string, fallback?: () => void) {
requestAnimationFrame(() => {
const el = root?.querySelector(`[data-tab-id="${id}"] [role="tab"]`)
if (el instanceof HTMLElement) {
el.focus()
return
}
fallback?.()
})
}
export function focusSelectedTab(root: ParentNode | null, fallback?: () => void) {
requestAnimationFrame(() => {
const el = root?.querySelector('[role="tab"][aria-selected="true"]')
if (el instanceof HTMLElement) {
el.focus()
return
}
fallback?.()
})
}
export const focusPrompt = () => window.dispatchEvent(new CustomEvent("focusPrompt", { detail: { restore: true } }))
export function handleTabKey(input: {
ids: readonly string[]
id: string
event: KeyboardEvent
select: (id: string) => void
root: ParentNode | null
}) {
if (input.event.target !== input.event.currentTarget) return
if (input.event.key === "Enter" || input.event.key === " ") {
input.event.preventDefault()
input.select(input.id)
return
}
if (input.event.metaKey || input.event.ctrlKey || input.event.shiftKey || input.event.altKey) return
const next = tabForKey(input.ids, input.id, input.event.key)
if (!next) return
input.event.preventDefault()
input.select(next)
focusTabElement(input.root, next)
}
export function createTabFocus(input: {
ids: () => readonly string[]
select: (id: string) => void
root?: () => ParentNode | null
fallback?: () => void
}) {
const root = () => input.root?.() ?? document
const fallback = input.fallback ?? focusPrompt
const restore = () => focusSelectedTab(root(), fallback)
return {
restore,
key: (id: string, event: KeyboardEvent) =>
handleTabKey({ ids: input.ids(), id, event, select: input.select, root: root() }),
run: (action: () => void) => {
action()
restore()
},
middle: (event: MouseEvent, action: () => void) => {
action()
if (event.button === 1) restore()
},
}
}
@@ -0,0 +1,17 @@
export function reorderTabs(tabs: readonly string[], from: string, to: string): string[] | undefined {
if (from === to) return undefined
const start = tabs.indexOf(from)
const end = tabs.indexOf(to)
if (start === -1 || end === -1) return undefined
const result = [...tabs]
result.splice(start, 1)
result.splice(end, 0, from)
return result
}
export function moveTab(tabs: readonly string[], id: string, offset: -1 | 1): string[] | undefined {
const index = tabs.indexOf(id)
const target = index + offset
if (index === -1 || target < 0 || target >= tabs.length) return undefined
return reorderTabs(tabs, id, tabs[target])
}
@@ -0,0 +1,21 @@
export function setTabWidths(frozen: boolean, root: ParentNode = document) {
const list = root.querySelector(".am-tab-list")
if (!(list instanceof HTMLElement)) return
list.toggleAttribute("data-tab-widths-frozen", frozen)
const tabs = Array.from(list.children).filter((child): child is HTMLElement => child instanceof HTMLElement)
for (const tab of tabs) {
if (frozen) {
const width = tab.getBoundingClientRect().width
tab.style.width = `${width}px`
tab.style.minWidth = `${width}px`
tab.style.flex = `0 0 ${width}px`
tab.style.maxWidth = `${width}px`
continue
}
tab.style.width = ""
tab.style.minWidth = ""
tab.style.flex = ""
tab.style.maxWidth = ""
}
}