mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
feat(vscode): complete local session tab workflow
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user