diff --git a/.changeset/hosted-sessions-vscode.md b/.changeset/hosted-sessions-vscode.md new file mode 100644 index 000000000000..2962ca206ca1 --- /dev/null +++ b/.changeset/hosted-sessions-vscode.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add experimental hosted cloud sessions: discover, start, read, and interact with Kilo Cloud sessions from VS Code through the local Kilo backend. Enable it with the `kilo-code.new.experimental.hostedSessions` setting. diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 8d8ec117e9ae..cd950b27f0d0 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -1099,6 +1099,12 @@ "scope": "application", "description": "Enable the experimental Integrated Browser panel and browser_open tool in Agent Manager." }, + "kilo-code.new.experimental.hostedSessions": { + "type": "boolean", + "default": false, + "scope": "application", + "description": "Discover, start, and interact with Kilo Cloud hosted sessions from VS Code through the local Kilo backend." + }, "kilo-code.new.experimental.conversationPromptHistory": { "type": "boolean", "default": false, diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 44f669830a78..064c8f4545f0 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -135,6 +135,17 @@ import { handleImportAndSend, type CloudSessionContext, } from "./kilo-provider/handlers/cloud-session" +import { + handleHostedDetach, + handleHostedOpen, + handleHostedProviders, + handleHostedSessions, + handleHostedStart, + hostedRepository, + type HostedContext, +} from "./kilo-provider/handlers/hosted" +import { isHostedKey } from "./hosted/identity" +import type { HostedTurn } from "./hosted/service" import { handlePermissionResponse, fetchAndSendPendingPermissions, @@ -148,6 +159,7 @@ import { import { fetchAndSendPendingSuggestions } from "./kilo-provider/handlers/suggestion" import { nativeTitle } from "./kilo-provider/native-tab-title" import { isActivity, type Activity } from "../webview-ui/src/utils/session-activity" +import { Identifier } from "../webview-ui/src/utils/id" import type { PRReviewCommentData, ReviewMessageData } from "./shared/review-comments" import { feedbackMetadata, parseFeedback, type BrowserFeedbackData } from "./shared/browser-feedback" import { mergeInjected } from "./shared/injected-prompt" @@ -514,6 +526,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private sessionGitDirectories = new Map() // Stable Git root resolved for each session. private sessionGitRecoveries = new Set() // Sessions whose older history was scanned for a Git root. private readonly aborts = new SessionAbort() + private readonly hostedUnsubs = new Map void>() private projectID: string | undefined // Current workspace project ID used to filter sessions. private loadMessagesAbort: AbortController | null = null // Current load request cancellation. private lastReconciledAt = new Map() // Per-session focus-mode reconcile timestamp. @@ -1165,6 +1178,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper for (const id of this.openSessionIds) { if (!next.has(id)) this.trackedSessionIds.delete(id) } + // A hosted session that is no longer open must stop streaming: its SSE + // reader re-attaches with backoff forever otherwise. + for (const key of [...this.hostedUnsubs.keys()]) { + if (next.has(key)) continue + this.releaseHosted(key) + } this.openSessionIds.clear() for (const id of next) { this.openSessionIds.add(id) @@ -1311,6 +1330,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } if (this.handleEditorOpenMessage(message)) return + if (await this.handleHostedMessage(message)) return if (await this.handleMcpMessage(message)) return if (await this.handleAgentManagerSettingsMessage(message)) return if (await this.handleAutoCleanupMessage(message)) return @@ -4732,6 +4752,23 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } + if (isHostedKey(sessionID)) { + await this.sendHostedMessage({ + key: sessionID, + text, + messageID, + providerID, + modelID, + agent, + variant, + files, + review, + browserFeedback, + injectedTitle, + }) + return + } + let resolved: { sid: string; dir: string } | undefined try { const sandbox = this.sandboxTransitions.get( @@ -5097,6 +5134,311 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Cloud session handlers extracted to kilo-provider/handlers/cloud-session.ts + private get hostedCtx(): HostedContext { + return { + hosted: this.connectionService.hosted, + postMessage: (msg) => this.postMessage(msg), + mapTurns: (items) => this.mapHostedTurns(items), + watch: (key) => { + this.trackedSessionIds.add(key) + this.watchHosted(key) + }, + } + } + + /** Hosted discovery, open, start, and detach, routed ahead of the main switch. */ + private async handleHostedMessage(message: TypedWebviewMessage & Record): Promise { + switch (message.type) { + case "requestHostedProviders": + await handleHostedProviders(this.hostedCtx) + return true + case "requestHostedSessions": + await handleHostedSessions(this.hostedCtx, typeof message.limit === "number" ? message.limit : undefined) + return true + case "hostedOpenSession": + if (typeof message.providerID === "string" && typeof message.sessionID === "string") { + await handleHostedOpen( + this.hostedCtx, + message.providerID, + message.sessionID, + typeof message.limit === "number" ? message.limit : undefined, + ) + } + return true + case "hostedStartSession": + await this.startHosted(message) + return true + case "hostedDetachSession": + if (typeof message.key === "string") { + this.openSessionIds.delete(message.key) + this.trackedSessionIds.delete(message.key) + this.releaseHosted(message.key) + } + return true + case "loadMessages": { + if (!isHostedKey(typeof message.sessionID === "string" ? message.sessionID : undefined)) return false + void this.handleHostedMessages(message.sessionID as string, { + mode: message.mode as MessageLoadMode | undefined, + before: typeof message.before === "string" ? message.before : undefined, + limit: typeof message.limit === "number" ? message.limit : undefined, + }) + return true + } + case "abort": { + const key = typeof message.sessionID === "string" ? message.sessionID : undefined + if (!isHostedKey(key)) return false + this.cancelRetry(key) + await this.connectionService.hosted + .abort(key) + .catch((error) => console.error("[Kilo New] KiloProvider: Failed to abort hosted session:", error)) + this.publish(key, { type: "idle" }) + return true + } + case "permissionResponse": { + const key = typeof message.sessionID === "string" ? message.sessionID : undefined + if (!isHostedKey(key) || typeof message.permissionId !== "string") return false + const response = message.response + if (response !== "once" && response !== "always" && response !== "reject") return false + await this.connectionService.hosted + .replyPermission(key, message.permissionId, response) + .then(() => this.postMessage({ type: "permissionResolved", requestID: message.permissionId })) + .catch((error) => { + console.error("[Kilo New] KiloProvider: Failed to answer hosted permission:", error) + this.postMessage({ type: "permissionError", requestID: message.permissionId }) + }) + return true + } + case "questionReply": { + const key = typeof message.sessionID === "string" ? message.sessionID : undefined + if (!isHostedKey(key) || typeof message.requestID !== "string") return false + const answers = Array.isArray(message.answers) ? (message.answers as string[][]) : [] + this.noteFollowup(answers, key) + const ok = await this.connectionService.hosted + .replyQuestion(key, message.requestID, answers) + .then(() => true) + .catch((error) => { + console.error("[Kilo New] KiloProvider: Failed to answer hosted question:", error) + this.postMessage({ type: "questionError", requestID: message.requestID }) + return false + }) + if (!ok) this.pendingFollowup = null + return true + } + case "questionReject": { + const key = typeof message.sessionID === "string" ? message.sessionID : undefined + if (!isHostedKey(key) || typeof message.requestID !== "string") return false + this.pendingFollowup = null + await this.connectionService.hosted.rejectQuestion(key, message.requestID).catch((error) => { + console.error("[Kilo New] KiloProvider: Failed to reject hosted question:", error) + this.postMessage({ type: "questionError", requestID: message.requestID }) + }) + return true + } + default: + return false + } + } + + private async startHosted(message: Record): Promise { + const repository = hostedRepository({ + type: typeof message.repositoryType === "string" ? message.repositoryType : undefined, + repo: typeof message.repo === "string" ? message.repo : undefined, + url: typeof message.url === "string" ? message.url : undefined, + branch: typeof message.branch === "string" ? message.branch : undefined, + }) + if ( + !repository || + typeof message.prompt !== "string" || + typeof message.mode !== "string" || + typeof message.model !== "string" || + typeof message.operationKey !== "string" || + typeof message.messageID !== "string" + ) { + this.postMessage({ + type: "hostedSessionFailed", + error: "Hosted session start requires a repository, prompt, agent, and model", + }) + return + } + await handleHostedStart(this.hostedCtx, { + repository, + prompt: message.prompt, + mode: message.mode, + model: message.model, + ...(typeof message.organizationID === "string" ? { organizationID: message.organizationID } : {}), + operationKey: message.operationKey, + messageID: message.messageID, + }) + } + + private mapHostedTurns(items: HostedTurn[]) { + return items.map((item) => ({ + ...this.slimInfo(item.info), + parts: this.slimParts(item.parts), + createdAt: new Date(item.info.time.created).toISOString(), + })) + } + + /** Mirrors `handleLoadMessages` for a hosted key without touching local routing. */ + private async handleHostedMessages( + key: string, + options: { mode?: MessageLoadMode; limit?: number; before?: string }, + ): Promise { + this.trackedSessionIds.add(key) + try { + const page = await this.connectionService.hosted.transcript(key, options.before, options.limit) + this.postMessage({ + type: "messagesLoaded", + sessionID: key, + messages: this.mapHostedTurns(page.items), + mode: options.mode ?? "replace", + cursor: page.cursor, + hasMore: Boolean(page.cursor), + }) + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to load hosted messages:", error) + this.postMessage({ + type: "error", + message: getErrorMessage(error) || "Failed to load hosted messages", + sessionID: key, + }) + } + } + + /** Re-reads every hosted state surface after continuity could not be proven. */ + private async resnapshotHosted(key: string): Promise { + try { + const open = await this.connectionService.hosted.resnapshot(key) + this.postMessage({ + type: "messagesLoaded", + sessionID: key, + messages: this.mapHostedTurns(open.items), + mode: "replace", + cursor: open.cursor, + hasMore: Boolean(open.cursor), + }) + const pending = await this.connectionService.hosted.pending(key) + for (const [sessionID, status] of Object.entries(pending.status)) { + if (sessionID !== open.session.id) continue + this.postMessage({ type: "sessionStatus", sessionID: key, status: status.type }) + } + for (const question of pending.questions as Array>) { + this.postMessage({ + type: "questionRequest", + question: { ...question, sessionID: key }, + }) + } + for (const permission of pending.permissions as Array>) { + this.postMessage({ + type: "permissionRequest", + permission: { + id: permission.id, + sessionID: key, + toolName: permission.permission, + patterns: permission.patterns, + always: permission.always, + args: permission.metadata, + message: `Permission required: ${String(permission.permission)}`, + tool: permission.tool, + }, + }) + } + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to resnapshot hosted session:", error) + } + } + + private watchHosted(key: string): void { + if (this.hostedUnsubs.has(key)) return + const off = this.connectionService.hosted.subscribe( + key, + (payload) => this.handleHostedEvent(key, payload), + (failure) => { + // Terminal: drop the subscription so a later action can resubscribe. + this.releaseHosted(key) + this.postMessage({ + type: "hostedSessionFailed", + error: failure.message, + code: failure.code, + sessionID: key, + }) + }, + () => void this.resnapshotHosted(key), + ) + this.hostedUnsubs.set(key, off) + } + + /** Stops streaming a hosted session and drops its local attachment. */ + private releaseHosted(key: string): void { + const off = this.hostedUnsubs.get(key) + this.hostedUnsubs.delete(key) + off?.() + void handleHostedDetach(this.hostedCtx, key) + } + + private handleHostedEvent(key: string, payload: unknown): void { + const event = payload as Parameters[0] + if (!event || typeof event.type !== "string") return + if (event.type === "question.asked" || event.type === "permission.asked") { + const properties = (event as { properties?: { id?: unknown } }).properties + if (typeof properties?.id === "string") { + this.connectionService.recordQuestionDirectory(properties.id, key) + this.connectionService.recordPermissionDirectory(properties.id, key, key) + } + } + const msg = mapSSEEventToWebviewMessage(event, key) + if (msg) this.postMessage(msg) + } + + private async sendHostedMessage(input: { + key: string + text: string + messageID?: string + providerID?: string + modelID?: string + agent?: string + variant?: string + files?: MessageFile[] + review?: ReviewMessageData + browserFeedback?: BrowserFeedbackData + injectedTitle?: string + }): Promise { + const parts: Array> = [] + for (const file of input.files ?? []) { + parts.push({ type: "file", mime: file.mime, url: file.url, filename: file.filename, source: file.source }) + } + parts.push({ + type: "text", + text: input.text, + metadata: mergeInjected(feedbackMetadata(input.review, input.browserFeedback), input.injectedTitle), + }) + const messageID = input.messageID ?? Identifier.ascending("message") + try { + this.trackedSessionIds.add(input.key) + this.connectionService.recordMessageSessionId(messageID, input.key) + this.watchHosted(input.key) + await this.connectionService.hosted.prompt(input.key, { + messageID, + parts, + agent: input.agent, + model: input.providerID && input.modelID ? { providerID: input.providerID, modelID: input.modelID } : undefined, + variant: input.variant, + }) + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to send hosted message:", error) + this.postMessage({ + type: "sendMessageFailed", + error: getErrorMessage(error) || "Failed to send message", + text: input.text, + sessionID: input.key, + messageID, + files: input.files, + review: input.review, + browserFeedback: input.browserFeedback, + }) + } + } + private get cloudSessionCtx(): CloudSessionContext { const self = this return { @@ -6318,6 +6660,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper */ dispose(): void { this.opts.onHidden?.() + for (const off of this.hostedUnsubs.values()) off() + this.hostedUnsubs.clear() if (this.opts.focusContext) { void vscode.commands.executeCommand("setContext", this.opts.focusContext, false) } diff --git a/packages/kilo-vscode/src/diff/sources/catalog.ts b/packages/kilo-vscode/src/diff/sources/catalog.ts index 0ab753a88d91..957a18880bfa 100644 --- a/packages/kilo-vscode/src/diff/sources/catalog.ts +++ b/packages/kilo-vscode/src/diff/sources/catalog.ts @@ -4,6 +4,7 @@ import { GitOps } from "../../agent-manager/GitOps" import { gitGeneratedFiles } from "../shared/git-attributes" import { resolveLocalDiffTarget } from "../shared/target" import { appendOutput, getWorkspaceRoot } from "../../review-utils" +import { isHostedKey } from "../../hosted/identity" import type { BranchListItem } from "../../agent-manager/git-import" import type { PanelContext } from "../types" import type { DiffSource, DiffSourceDescriptor } from "./types" @@ -44,6 +45,7 @@ export interface WorkspaceBranchesResult { */ export class DiffSourceCatalog implements vscode.Disposable { private readonly sessionFetch: SessionDiffFetch = async ({ sessionID, directory }) => { + if (isHostedKey(sessionID)) return [] const client = this.connection.getClient() const { data } = await client.session.diff({ sessionID, directory }, { throwOnError: true }) return data ?? [] @@ -53,8 +55,12 @@ export class DiffSourceCatalog implements vscode.Disposable { * Turn diffs are stored on the user message itself (`summary.diffs`), not * on the session-level snapshot. The `/session/:id/diff` endpoint ignores * its `messageID` param today, so we fetch the message directly instead. + * + * Single-message reads are not part of the hosted native allowlist, so a + * hosted session returns no turn diffs rather than failing the view. */ private readonly turnFetch: TurnDiffFetch = async ({ sessionID, messageID, directory }) => { + if (isHostedKey(sessionID)) return [] const client = this.connection.getClient() const { data } = await client.session.message({ sessionID, messageID, directory }, { throwOnError: true }) const info = data?.info diff --git a/packages/kilo-vscode/src/hosted/events.ts b/packages/kilo-vscode/src/hosted/events.ts new file mode 100644 index 000000000000..31a58b278960 --- /dev/null +++ b/packages/kilo-vscode/src/hosted/events.ts @@ -0,0 +1,53 @@ +/** + * Hosted stream framing. + * + * The local server relays the provider's native SSE frames unchanged. It does + * not define a schema for them, so the client has to recognise three shapes: + * ordinary events, generation fences, and full-resnapshot requests. Anything + * unrecognised is forwarded as an event; a resnapshot always wins over a + * concurrent event because continuity can no longer be proven. + */ +export type FrameKind = "event" | "fence" | "resnapshot" | "ignore" + +export interface Frame { + kind: FrameKind + payload?: unknown + directory?: string +} + +function object(value: unknown): Record | undefined { + if (value && typeof value === "object") return value as Record + return +} + +export function classifyFrame(item: unknown): Frame { + const envelope = object(item) + if (!envelope) return { kind: "ignore" } + const payload = object(envelope.payload) ?? envelope + const type = typeof payload.type === "string" ? payload.type : undefined + const directory = typeof envelope.directory === "string" ? envelope.directory : undefined + if (!type) return { kind: "ignore" } + if (type === "kilo.native.resnapshot") return { kind: "resnapshot", payload, directory } + if (type === "kilo.native.fence") return { kind: "fence", payload, directory } + return { kind: "event", payload, directory } +} + +/** + * Rewrites the native session identity carried by a hosted event to the + * namespaced view key, so the existing renderer and stores can key on it + * without knowing about attachments. + */ +export function retag(payload: unknown, sessionID: string, key: string): unknown { + const event = object(payload) + if (!event) return payload + const properties = object(event.properties) + if (!properties) return payload + if (properties.sessionID !== sessionID) return payload + const next: Record = { ...properties, sessionID: key } + for (const field of ["info", "part"]) { + const value = object(properties[field]) + if (!value || value.sessionID !== sessionID) continue + next[field] = { ...value, sessionID: key } + } + return { ...event, properties: next } +} diff --git a/packages/kilo-vscode/src/hosted/failure.ts b/packages/kilo-vscode/src/hosted/failure.ts new file mode 100644 index 000000000000..2cda24019c19 --- /dev/null +++ b/packages/kilo-vscode/src/hosted/failure.ts @@ -0,0 +1,86 @@ +/** + * Attachment failures. + * + * Routed-attachment errors are not part of the generated SDK error union: the + * server answers `{ error: "KILO_SESSION_ATTACHMENT", code, message }`. The + * session-host control plane answers `{ code, message }`. Both shapes are + * parsed here so callers can decide between re-attaching and surfacing a + * failure. There is never a local fallback for a failed attachment. + */ +const CODES = [ + "unknown", + "expired", + "disposed", + "unavailable", + "too_large", + "invalid_request", + "auth_unavailable", + "signed_out", + "unauthorized", + "payment_required", + "not_found", + "invalid_response", + "rejected", + "unknown_provider", +] as const + +export type AttachmentCode = (typeof CODES)[number] + +export interface AttachmentFailure { + code: AttachmentCode + message: string + /** True when re-attaching can plausibly restore the session. */ + recoverable: boolean +} + +const KNOWN = new Set(CODES) + +const RECOVERABLE = new Set(["unknown", "expired", "disposed", "unavailable", "auth_unavailable"]) + +function object(value: unknown): Record | undefined { + if (typeof value === "string") { + return parse(value) + } + if (value && typeof value === "object") return value as Record + return +} + +function parse(text: string): Record | undefined { + if (!text.startsWith("{")) return + try { + const value = JSON.parse(text) as unknown + if (value && typeof value === "object") return value as Record + } catch { + return + } + return +} + +export function attachmentFailure(error: unknown): AttachmentFailure | undefined { + const body = bodyOf(error) + if (!body) return + const code = typeof body.code === "string" ? body.code : undefined + if (!code || !KNOWN.has(code)) return + const message = typeof body.message === "string" ? body.message : code + return { code: code as AttachmentCode, message, recoverable: RECOVERABLE.has(code as AttachmentCode) } +} + +/** + * The SDK wraps thrown bodies in an `Error` whose `cause.body` holds the parsed + * response, so both the raw body and the wrapped form are understood. + */ +function bodyOf(error: unknown): Record | undefined { + const direct = object(error) + if (direct && typeof direct.code === "string") return direct + if (direct) { + const cause = object(direct.cause) + const body = cause ? object(cause.body) : undefined + if (body) return body + return direct + } + return +} + +export function isRecoverable(error: unknown): boolean { + return attachmentFailure(error)?.recoverable === true +} diff --git a/packages/kilo-vscode/src/hosted/identity.ts b/packages/kilo-vscode/src/hosted/identity.ts new file mode 100644 index 000000000000..5f360dc327c2 --- /dev/null +++ b/packages/kilo-vscode/src/hosted/identity.ts @@ -0,0 +1,33 @@ +/** + * Hosted session identity. + * + * A provider's native session id is not unique across providers, and the + * extension keys its stores, tabs, and caches by a single string. Hosted + * sessions therefore use a namespaced view key that can never collide with a + * local session id or the legacy `cloud:` preview key. + */ +const PREFIX = "host:" + +export interface HostedIdentity { + providerID: string + sessionID: string +} + +export function hostedKey(providerID: string, sessionID: string): string { + return `${PREFIX}${providerID}:${sessionID}` +} + +export function isHostedKey(key: string | null | undefined): key is string { + return typeof key === "string" && key.startsWith(PREFIX) +} + +export function parseHostedKey(key: string | null | undefined): HostedIdentity | undefined { + if (!isHostedKey(key)) return + const rest = key.slice(PREFIX.length) + const index = rest.indexOf(":") + if (index <= 0) return + const providerID = rest.slice(0, index) + const sessionID = rest.slice(index + 1) + if (!providerID || !sessionID) return + return { providerID, sessionID } +} diff --git a/packages/kilo-vscode/src/hosted/registry.ts b/packages/kilo-vscode/src/hosted/registry.ts new file mode 100644 index 000000000000..7da2be25b9de --- /dev/null +++ b/packages/kilo-vscode/src/hosted/registry.ts @@ -0,0 +1,109 @@ +/** + * Local attachment registry. + * + * Attachments are process-local on the backend, so the client caches at most + * one per hosted identity and treats a backend restart as invalidation. A + * burst of requests for the same identity shares one attach call, and a + * detach or invalidation always wins over an in-flight attach. + */ +export interface Attachment { + key: string + providerID: string + sessionID: string + attachmentID: string + /** Provider-declared descriptor expiry. A hint for diagnostics only. */ + expiresAt: number +} + +export interface AttachmentPort { + attach(input: { providerID: string; sessionID: string }): Promise<{ attachmentID: string; expiresAt: number }> + detach(attachmentID: string): Promise +} + +interface Pending { + promise: Promise + resolve: (attachment: Attachment) => void + reject: (error: unknown) => void +} + +export class HostedRegistry { + private readonly entries = new Map() + private readonly pending = new Map() + private readonly versions = new Map() + + private version(key: string): number { + return this.versions.get(key) ?? 0 + } + + private stale(key: string): void { + this.versions.set(key, this.version(key) + 1) + this.entries.delete(key) + const pending = this.pending.get(key) + this.pending.delete(key) + pending?.reject(new Error("Hosted attachment was invalidated")) + } + + get(key: string): Attachment | undefined { + return this.entries.get(key) + } + + keys(): string[] { + return [...this.entries.keys()] + } + + ensure(key: string, identity: { providerID: string; sessionID: string }, port: AttachmentPort): Promise { + const found = this.entries.get(key) + if (found) return Promise.resolve(found) + const pending = this.pending.get(key) + if (pending) return pending.promise + + // The rejection is delivered to callers; also mark it handled so an + // invalidation during shutdown cannot surface as an unhandled rejection. + const deferred = Promise.withResolvers() + deferred.promise.catch(() => undefined) + const start = this.version(key) + this.pending.set(key, { promise: deferred.promise, resolve: deferred.resolve, reject: deferred.reject }) + + port.attach(identity).then( + (value) => { + const entry: Attachment = { + key, + providerID: identity.providerID, + sessionID: identity.sessionID, + attachmentID: value.attachmentID, + expiresAt: value.expiresAt, + } + if (this.version(key) !== start) { + // Invalidation or a detach won the race. The backend attachment is + // already minted, so release it instead of leaking it until expiry. + port.detach(value.attachmentID).catch(() => undefined) + deferred.reject(new Error("Hosted attachment was invalidated")) + return + } + this.entries.set(key, entry) + if (this.pending.get(key)?.promise === deferred.promise) this.pending.delete(key) + deferred.resolve(entry) + }, + (error: unknown) => { + if (this.pending.get(key)?.promise === deferred.promise) this.pending.delete(key) + deferred.reject(error) + }, + ) + return deferred.promise + } + + invalidate(key: string): void { + this.stale(key) + } + + async detach(key: string, port: AttachmentPort): Promise { + const found = this.entries.get(key) + this.stale(key) + if (!found) return + await port.detach(found.attachmentID) + } + + reset(): void { + for (const key of new Set([...this.entries.keys(), ...this.pending.keys()])) this.stale(key) + } +} diff --git a/packages/kilo-vscode/src/hosted/service.ts b/packages/kilo-vscode/src/hosted/service.ts new file mode 100644 index 000000000000..ce5378f451a6 --- /dev/null +++ b/packages/kilo-vscode/src/hosted/service.ts @@ -0,0 +1,441 @@ +import type { + KiloClient, + Message, + Part, + Session, + SessionHostProviderStatus, + SessionHostStartInput, +} from "@kilocode/sdk/v2/client" +import { hostedKey, parseHostedKey, type HostedIdentity } from "./identity" +import { attachmentFailure, isRecoverable, type AttachmentFailure } from "./failure" +import { HostedRegistry, type Attachment, type AttachmentPort } from "./registry" +import { classifyFrame, retag } from "./events" + +export interface HostedSessionSummary { + key: string + providerID: string + sessionID: string + title: string + created: number + updated: number + directory?: string +} + +export interface HostedTurn { + info: Message + parts: Part[] +} + +export interface HostedOpen { + key: string + session: Session + items: HostedTurn[] + cursor?: string + omitted?: number +} + +export interface HostedStart { + key: string + providerID: string + sessionID: string + replayed: boolean + delivery: "sent" | "queued" +} + +export interface HostedStartInput { + repository: SessionHostStartInput["repository"] + prompt: string + mode: string + model: string + organizationID?: string + operationKey: string + messageID: string +} + +export type HostedHit = (payload: unknown) => void +export type HostedUnsubscribe = () => void + +interface Stream { + abort: AbortController + listeners: Set + failures: Set<(failure: AttachmentFailure) => void> + snapshots: Set<() => void> +} + +const RECONNECT_MS = 500 +const RECONNECT_MAX_MS = 5_000 + +/** + * Hosted session access through the shared local backend. + * + * Durable reads never attach. Live work attaches lazily and re-attaches once + * after a recoverable failure; an unrecoverable failure is surfaced, never + * answered with a local session. + */ +export class HostedService { + private readonly registry = new HostedRegistry() + private readonly identities = new Map() + private readonly streams = new Map() + private providers_cache: SessionHostProviderStatus[] = [] + + constructor(private readonly client: () => KiloClient | null) {} + + private port(): AttachmentPort { + const client = this.client() + if (!client) throw new Error("Not connected to the local Kilo backend") + return { + attach: async (input) => { + const { data } = await client.sessionHost.attach(input, { throwOnError: true }) + return { attachmentID: data.attachmentID, expiresAt: data.expiresAt } + }, + detach: async (attachmentID) => { + await client.sessionHost.detach({ attachmentID }, { throwOnError: true }).catch(() => undefined) + }, + } + } + + private require(): KiloClient { + const client = this.client() + if (!client) throw new Error("Not connected to the local Kilo backend") + return client + } + + remember(providerID: string, sessionID: string): string { + const key = hostedKey(providerID, sessionID) + this.identities.set(key, { providerID, sessionID }) + return key + } + + identity(key: string): HostedIdentity | undefined { + return this.identities.get(key) ?? parseHostedKey(key) + } + + async providers(): Promise { + const { data } = await this.require().sessionHost.providers({ throwOnError: true }) + this.providers_cache = data.providers + return data.providers + } + + cache(): SessionHostProviderStatus[] { + return this.providers_cache + } + + async list(limit = 50): Promise { + const { data } = await this.require().sessionHost.list({ limit: String(limit) }, { throwOnError: true }) + return data.items.map((item) => { + const key = this.remember(item.id.providerID, item.id.sessionID) + return { + key, + providerID: item.id.providerID, + sessionID: item.id.sessionID, + title: item.session.title, + created: item.session.time.created, + updated: item.session.time.updated, + directory: item.session.directory, + } + }) + } + + /** Opens a hosted session from durable history only. Never attaches or wakes compute. */ + async open(providerID: string, sessionID: string, limit = 80): Promise { + const key = this.remember(providerID, sessionID) + const client = this.require() + const [detail, page] = await Promise.all([ + client.sessionHost.get({ providerID, sessionID }, { throwOnError: true }), + client.sessionHost.transcript({ providerID, sessionID, limit: String(limit) }, { throwOnError: true }), + ]) + return { + key, + session: detail.data.session, + items: page.data.items, + cursor: page.data.cursor, + omitted: page.data.omitted, + } + } + + async transcript(key: string, before?: string, limit = 80): Promise<{ items: HostedTurn[]; cursor?: string }> { + const identity = this.need(key) + const { data } = await this.require().sessionHost.transcript( + { + providerID: identity.providerID, + sessionID: identity.sessionID, + limit: String(limit), + ...(before ? { before } : {}), + }, + { throwOnError: true }, + ) + return { items: data.items, ...(data.cursor ? { cursor: data.cursor } : {}) } + } + + async start(input: HostedStartInput): Promise { + const { data } = await this.require().sessionHost.start( + { + sessionHostStartInput: { + repository: input.repository, + prompt: input.prompt, + agent: { mode: input.mode, model: input.model }, + ...(input.organizationID ? { organizationID: input.organizationID } : {}), + origin: "desktop", + operationKey: input.operationKey, + messageID: input.messageID, + }, + }, + { throwOnError: true }, + ) + const key = this.remember(data.session.providerID, data.session.sessionID) + return { + key, + providerID: data.session.providerID, + sessionID: data.session.sessionID, + replayed: data.replayed, + delivery: data.delivery, + } + } + + async attach(key: string): Promise { + const identity = this.need(key) + const entry = await this.registry.ensure(key, identity, this.port()) + return entry.attachmentID + } + + async detach(key: string): Promise { + this.stop(key) + await this.registry.detach(key, this.port()) + } + + /** Drops the cached attachment so the next request mints a fresh one. */ + invalidate(key: string): void { + this.registry.invalidate(key) + } + + /** Backend restarts invalidate every attachment. */ + reset(): void { + for (const [key, stream] of [...this.streams]) { + this.streams.delete(key) + stream.abort.abort() + // Tell clients the stream is gone so they can resubscribe after reconnect. + for (const hit of stream.failures) { + hit({ code: "unavailable", message: "The local Kilo backend restarted", recoverable: true }) + } + } + this.registry.reset() + this.providers_cache = [] + } + + /** + * Runs one native request against the current attachment. A recoverable + * attachment failure mints exactly one replacement and retries; anything + * else is surfaced, and no request ever falls back to local routing. + */ + private async withAttachment(key: string, run: (attachmentID: string) => Promise): Promise { + const attachmentID = await this.attach(key) + try { + return await run(attachmentID) + } catch (error) { + if (!isRecoverable(error)) throw error + this.registry.invalidate(key) + return run(await this.attach(key)) + } + } + + async prompt( + key: string, + input: { + messageID: string + parts: Array> + agent?: string + model?: { providerID: string; modelID: string } + variant?: string + editorContext?: Record + }, + ): Promise { + const identity = this.need(key) + await this.withAttachment(key, (workspace) => + this.require().session.promptAsync( + { + sessionID: identity.sessionID, + workspace, + messageID: input.messageID, + parts: input.parts as never, + ...(input.agent ? { agent: input.agent } : {}), + ...(input.model ? { model: input.model } : {}), + ...(input.variant ? { variant: input.variant } : {}), + ...(input.editorContext ? { editorContext: input.editorContext as never } : {}), + }, + { throwOnError: true }, + ), + ) + } + + async abort(key: string): Promise { + const identity = this.need(key) + await this.withAttachment(key, (workspace) => + this.require().session.abort({ sessionID: identity.sessionID, workspace }, { throwOnError: true }), + ) + } + + async messages(key: string, input: { limit: number; before?: string }) { + const identity = this.need(key) + return this.withAttachment(key, (workspace) => + this.require().session.messages( + { + sessionID: identity.sessionID, + workspace, + limit: input.limit, + ...(input.before ? { before: input.before } : {}), + }, + { throwOnError: true }, + ), + ) + } + + async session(key: string): Promise { + const identity = this.need(key) + const result = await this.withAttachment(key, (workspace) => + this.require().session.get({ sessionID: identity.sessionID, workspace }, { throwOnError: true }), + ) + return result.data + } + + /** Re-reads every state surface after continuity could not be proven. */ + async resnapshot(key: string): Promise { + const identity = this.need(key) + return this.open(identity.providerID, identity.sessionID) + } + + async replyQuestion(key: string, requestID: string, answers: string[][]): Promise { + await this.withAttachment(key, (workspace) => + this.require().question.reply({ requestID, workspace, answers: answers as never }, { throwOnError: true }), + ) + } + + async rejectQuestion(key: string, requestID: string): Promise { + await this.withAttachment(key, (workspace) => + this.require().question.reject({ requestID, workspace }, { throwOnError: true }), + ) + } + + async replyPermission(key: string, requestID: string, reply: "once" | "always" | "reject"): Promise { + await this.withAttachment(key, (workspace) => + this.require().permission.reply({ requestID, workspace, reply }, { throwOnError: true }), + ) + } + + async pending( + key: string, + ): Promise<{ questions: unknown[]; permissions: unknown[]; status: Record }> { + return this.withAttachment(key, async (workspace) => { + const client = this.require() + const [questions, permissions, status] = await Promise.all([ + client.question.list({ workspace }, { throwOnError: true }), + client.permission.list({ workspace }, { throwOnError: true }), + client.session.status({ workspace }, { throwOnError: true }), + ]) + return { questions: questions.data, permissions: permissions.data, status: status.data } + }) + } + + subscribe( + key: string, + listener: HostedHit, + onFailure?: (failure: AttachmentFailure) => void, + onSnapshot?: () => void, + ): HostedUnsubscribe { + const stream = this.streams.get(key) ?? this.begin(key) + stream.listeners.add(listener) + if (onFailure) stream.failures.add(onFailure) + if (onSnapshot) stream.snapshots.add(onSnapshot) + return () => { + stream.listeners.delete(listener) + if (onFailure) stream.failures.delete(onFailure) + if (onSnapshot) stream.snapshots.delete(onSnapshot) + if (stream.listeners.size === 0) this.stop(key) + } + } + + private begin(key: string): Stream { + const stream: Stream = { + abort: new AbortController(), + listeners: new Set(), + failures: new Set(), + snapshots: new Set(), + } + this.streams.set(key, stream) + void this.run(key, stream) + return stream + } + + private stop(key: string): void { + const stream = this.streams.get(key) + if (!stream) return + this.streams.delete(key) + stream.abort.abort() + } + + private need(key: string): HostedIdentity { + const identity = this.identity(key) + if (!identity) throw new Error(`Not a hosted session key: ${key}`) + return identity + } + + private async run(key: string, stream: Stream): Promise { + const identity = this.need(key) + let delay = RECONNECT_MS + while (!stream.abort.signal.aborted) { + let attachmentID: string + try { + attachmentID = await this.attach(key) + } catch (error) { + this.stop(key) + this.fail(stream, error) + return + } + try { + const events = await this.require().event.subscribe( + { workspace: attachmentID }, + { signal: stream.abort.signal, sseMaxRetryAttempts: 1 }, + ) + delay = RECONNECT_MS + for await (const item of events.stream) { + if (stream.abort.signal.aborted) return + const frame = classifyFrame(item) + if (frame.kind === "resnapshot") { + for (const hit of stream.snapshots) hit() + continue + } + if (frame.kind !== "event") continue + const payload = retag(frame.payload, identity.sessionID, key) + for (const hit of stream.listeners) hit(payload) + } + } catch (error) { + if (stream.abort.signal.aborted) return + const failure = attachmentFailure(error) + if (failure?.recoverable) { + this.registry.invalidate(key) + } else { + this.stop(key) + this.fail(stream, error) + return + } + } + if (stream.abort.signal.aborted) return + await new Promise((resolve) => setTimeout(resolve, delay)) + delay = Math.min(delay * 2, RECONNECT_MAX_MS) + } + } + + private fail(stream: Stream, error: unknown) { + const failure = attachmentFailure(error) + const value: AttachmentFailure = failure ?? { + code: "unavailable", + message: error instanceof Error ? error.message : "Hosted session is unavailable", + recoverable: false, + } + for (const hit of stream.failures) hit(value) + } + + /** Test/inspection hook: the current attachment for a key, when one exists. */ + attachment(key: string): Attachment | undefined { + return this.registry.get(key) + } +} diff --git a/packages/kilo-vscode/src/kilo-provider/chat-settings.ts b/packages/kilo-vscode/src/kilo-provider/chat-settings.ts index 9c385cd20283..e163902e3267 100644 --- a/packages/kilo-vscode/src/kilo-provider/chat-settings.ts +++ b/packages/kilo-vscode/src/kilo-provider/chat-settings.ts @@ -12,6 +12,7 @@ export function buildChatSettingsMessage() { browserAutomation: vscode.workspace .getConfiguration("kilo-code.new.experimental") .get("browserAutomation", false), + hostedSessions: vscode.workspace.getConfiguration("kilo-code.new.experimental").get("hostedSessions", false), agentManagerBrowserOpenLinksIn: integratedBrowserLinkDestination(), workspaceTrusted: vscode.workspace.isTrusted, }, @@ -31,6 +32,7 @@ export function watchChatConfig(post: Post): vscode.Disposable { if ( event.affectsConfiguration("kilo-code.new.chat") || event.affectsConfiguration("kilo-code.new.experimental.browserAutomation") || + event.affectsConfiguration("kilo-code.new.experimental.hostedSessions") || event.affectsConfiguration("kilo-code.new.agentManager.browser.openLinksIn") ) { post(buildChatSettingsMessage()) diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/hosted.ts b/packages/kilo-vscode/src/kilo-provider/handlers/hosted.ts new file mode 100644 index 000000000000..dc573374f3f7 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/handlers/hosted.ts @@ -0,0 +1,125 @@ +/** + * Hosted session handlers — extracted from KiloProvider. + * + * Discovery, durable open, and start for hosted sessions. No vscode + * dependency, and no direct provider transport: everything goes through the + * local backend's session-host surface. + */ +import type { SessionHostStartInput } from "@kilocode/sdk/v2/client" +import type { HostedService, HostedStartInput, HostedTurn } from "../../hosted/service" +import { attachmentFailure } from "../../hosted/failure" +import { hostedKey } from "../../hosted/identity" + +export interface HostedContext { + readonly hosted: HostedService + postMessage(msg: unknown): void + /** Converts native turns into the webview transcript shape. */ + mapTurns(items: HostedTurn[]): unknown[] + /** Starts live event delivery for a session that is already running. */ + watch?(key: string): void +} + +function fail(ctx: HostedContext, error: unknown, sessionID?: string) { + const failure = attachmentFailure(error) + const message = failure?.message ?? (error instanceof Error ? error.message : "Hosted session request failed") + ctx.postMessage({ type: "hostedSessionFailed", error: message, code: failure?.code, sessionID }) +} + +export async function handleHostedProviders(ctx: HostedContext): Promise { + try { + const providers = await ctx.hosted.providers() + ctx.postMessage({ type: "hostedProvidersLoaded", providers }) + } catch (error) { + console.error("[Kilo New] Failed to read hosted providers:", error) + ctx.postMessage({ + type: "hostedProvidersLoaded", + providers: [], + error: error instanceof Error ? error.message : "Hosted providers are unavailable", + }) + } +} + +export async function handleHostedSessions(ctx: HostedContext, limit?: number): Promise { + try { + const sessions = await ctx.hosted.list(limit) + ctx.postMessage({ type: "hostedSessionsLoaded", sessions }) + } catch (error) { + console.error("[Kilo New] Failed to list hosted sessions:", error) + ctx.postMessage({ + type: "hostedSessionsLoaded", + sessions: [], + error: error instanceof Error ? error.message : "Hosted sessions are unavailable", + }) + } +} + +/** Durable open: reads stored history without attaching or waking compute. */ +export async function handleHostedOpen( + ctx: HostedContext, + providerID: string, + sessionID: string, + limit?: number, +): Promise { + try { + const open = await ctx.hosted.open(providerID, sessionID, limit) + ctx.postMessage({ + type: "hostedSessionLoaded", + key: open.key, + providerID, + sessionID, + session: open.session, + title: open.session.title || "Hosted session", + messages: ctx.mapTurns(open.items), + cursor: open.cursor, + omitted: open.omitted, + mode: "replace", + }) + } catch (error) { + console.error("[Kilo New] Failed to open hosted session:", error) + const key = hostedKey(providerID, sessionID) + fail(ctx, error, key) + ctx.postMessage({ + type: "error", + sessionID: key, + message: + attachmentFailure(error)?.message ?? (error instanceof Error ? error.message : "Hosted session is unavailable"), + }) + } +} + +export async function handleHostedStart(ctx: HostedContext, input: HostedStartInput): Promise { + try { + const start = await ctx.hosted.start(input) + ctx.watch?.(start.key) + ctx.postMessage({ type: "hostedSessionStarted", ...start }) + } catch (error) { + console.error("[Kilo New] Failed to start hosted session:", error) + fail(ctx, error) + } +} + +export async function handleHostedDetach(ctx: HostedContext, key: string): Promise { + try { + await ctx.hosted.detach(key) + } catch (error) { + console.error("[Kilo New] Failed to detach hosted session:", error) + } + ctx.postMessage({ type: "hostedSessionDetached", key }) +} + +export function hostedRepository(input: { + type?: string + repo?: string + url?: string + branch?: string +}): SessionHostStartInput["repository"] | undefined { + const branch = input.branch?.trim() + const repo = input.repo?.trim() + const url = input.url?.trim() + if (input.type === "github" && repo) return { type: "github", repo, ...(branch ? { branch } : {}) } + if (input.type === "gitlab" && url) return { type: "gitlab", url, ...(branch ? { branch } : {}) } + if (input.type === "git" && url) return { type: "git", url, ...(branch ? { branch } : {}) } + if (repo) return { type: "github", repo, ...(branch ? { branch } : {}) } + if (url) return { type: "git", url, ...(branch ? { branch } : {}) } + return +} diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index a573bbd71bc9..8fc0ef6f3110 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -6,6 +6,7 @@ import type { ServerConfig } from "./types" import { createDuplicateEventFilter, resolveEventSessionId as resolveEventSessionIdPure } from "./connection-utils" import { SandboxPreference } from "../sandbox-preference" import { ExplicitAbortState } from "./explicit-abort" +import { HostedService } from "../../hosted/service" import type { PermissionResponseResult } from "../../kilo-provider/handlers/permission-handler" export type ConnectionState = "connecting" | "connected" | "disconnected" | "error" @@ -88,6 +89,7 @@ async function drainNetworkWaits(client: KiloClient, dir: string) { */ export class KiloConnectionService { readonly sandboxPreference: SandboxPreference + readonly hosted = new HostedService(() => this.client) private readonly serverManager: ServerManager private client: KiloClient | null = null private sseClient: SdkSSEAdapter | null = null @@ -939,6 +941,7 @@ export class KiloConnectionService { this.sseClient = null sse?.disconnect() this.client = null + this.hosted.reset() this.config = null this.info = null this.permissionDirectories.clear() diff --git a/packages/kilo-vscode/tests/unit/chat-settings-message.test.ts b/packages/kilo-vscode/tests/unit/chat-settings-message.test.ts index c41ec35480ac..4095072f66b3 100644 --- a/packages/kilo-vscode/tests/unit/chat-settings-message.test.ts +++ b/packages/kilo-vscode/tests/unit/chat-settings-message.test.ts @@ -85,6 +85,7 @@ describe("buildChatSettingsMessage", () => { settings: { shiftTabCyclesVariant: true, browserAutomation: true, + hostedSessions: false, agentManagerBrowserOpenLinksIn: "external", workspaceTrusted: true, }, diff --git a/packages/kilo-vscode/tests/unit/hosted-core.test.ts b/packages/kilo-vscode/tests/unit/hosted-core.test.ts new file mode 100644 index 000000000000..c4aab4da40e7 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/hosted-core.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test" +import { attachmentFailure, isRecoverable } from "../../src/hosted/failure" +import { classifyFrame, retag } from "../../src/hosted/events" +import { HostedRegistry } from "../../src/hosted/registry" + +describe("attachment failures", () => { + test("recoverable attachment codes allow one re-attach", () => { + for (const code of ["unknown", "expired", "disposed", "unavailable"]) { + const failure = attachmentFailure({ error: "KILO_SESSION_ATTACHMENT", code, message: code }) + expect(failure?.code).toBe(code as never) + expect(isRecoverable({ code })).toBe(true) + } + }) + + test("unrecoverable codes surface instead of re-attaching", () => { + for (const code of ["invalid_request", "too_large", "payment_required", "signed_out"]) { + expect(isRecoverable({ code })).toBe(false) + } + }) + + test("parses a JSON string body and ignores unrelated errors", () => { + expect(attachmentFailure('{"code":"expired","message":"gone"}')?.code).toBe("expired") + expect(attachmentFailure({ code: "KILO_LIVE_RUNTIME_UNAVAILABLE" })).toBeUndefined() + expect(attachmentFailure(new Error("boom"))).toBeUndefined() + expect(attachmentFailure("not json")).toBeUndefined() + }) + + test("reads the body the SDK preserves under Error.cause", () => { + const wrapped = new Error("Session attachment has expired", { + cause: { body: { error: "KILO_SESSION_ATTACHMENT", code: "expired", message: "Session attachment has expired" } }, + }) + expect(attachmentFailure(wrapped)?.code).toBe("expired") + expect(isRecoverable(wrapped)).toBe(true) + }) +}) + +describe("hosted stream frames", () => { + test("classifies resnapshot and fence frames ahead of ordinary events", () => { + expect( + classifyFrame({ payload: { type: "kilo.native.resnapshot", properties: { reason: "overflow" } } }).kind, + ).toBe("resnapshot") + expect(classifyFrame({ payload: { type: "kilo.native.fence", properties: { generation: 1 } } }).kind).toBe("fence") + expect(classifyFrame({ type: "message.updated", properties: {} }).kind).toBe("event") + expect(classifyFrame({ payload: {} }).kind).toBe("ignore") + expect(classifyFrame(undefined).kind).toBe("ignore") + }) + + test("retags only the matching native session identity", () => { + const key = "host:kilo-cloud:ses_1" + const event = { + type: "message.part.updated", + properties: { sessionID: "ses_1", part: { id: "prt_1", sessionID: "ses_1" } }, + } + const next = retag(event, "ses_1", key) as typeof event + expect(next.properties.sessionID).toBe(key) + expect(next.properties.part.sessionID).toBe(key) + + const other = { type: "message.part.updated", properties: { sessionID: "ses_2" } } + expect(retag(other, "ses_1", key)).toBe(other) + }) +}) + +describe("attachment registry", () => { + const port = (calls: string[]) => ({ + attach: async () => { + calls.push("attach") + return { attachmentID: `wrk_att_${calls.length}`, expiresAt: Date.now() + 60_000 } + }, + detach: async () => { + calls.push("detach") + }, + }) + + test("coalesces concurrent attaches for one identity", async () => { + const registry = new HostedRegistry() + const calls: string[] = [] + const [a, b] = await Promise.all([ + registry.ensure("host:a:ses_1", { providerID: "a", sessionID: "ses_1" }, port(calls)), + registry.ensure("host:a:ses_1", { providerID: "a", sessionID: "ses_1" }, port(calls)), + ]) + expect(a.attachmentID).toBe(b.attachmentID) + expect(calls.filter((call) => call === "attach")).toHaveLength(1) + }) + + test("invalidation forces the next request to mint a new attachment", async () => { + const registry = new HostedRegistry() + const calls: string[] = [] + const first = await registry.ensure("host:a:ses_1", { providerID: "a", sessionID: "ses_1" }, port(calls)) + registry.invalidate("host:a:ses_1") + const second = await registry.ensure("host:a:ses_1", { providerID: "a", sessionID: "ses_1" }, port(calls)) + expect(second.attachmentID).not.toBe(first.attachmentID) + }) + + test("detach drops the entry and never throws for an unknown key", async () => { + const registry = new HostedRegistry() + const calls: string[] = [] + registry.ensure("host:a:ses_1", { providerID: "a", sessionID: "ses_1" }, port(calls)).catch(() => undefined) + await registry.detach("host:a:ses_1", port(calls)) + expect(registry.get("host:a:ses_1")).toBeUndefined() + await registry.detach("host:missing:ses_1", port(calls)) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/hosted-identity.test.ts b/packages/kilo-vscode/tests/unit/hosted-identity.test.ts new file mode 100644 index 000000000000..8ec842d6e8a8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/hosted-identity.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test" +import { hostedKey, isHostedKey, parseHostedKey } from "../../src/hosted/identity" + +describe("hosted identity", () => { + test("mints a namespaced key that cannot look local", () => { + expect(hostedKey("kilo-cloud", "ses_1")).toBe("host:kilo-cloud:ses_1") + expect(hostedKey("kilo-cloud", "ses_1").startsWith("ses_")).toBe(false) + expect(hostedKey("kilo-cloud", "ses_1").startsWith("cloud:")).toBe(false) + }) + + test("round-trips provider and native session identity", () => { + expect(parseHostedKey(hostedKey("kilo-cloud", "ses_1"))).toEqual({ + providerID: "kilo-cloud", + sessionID: "ses_1", + }) + }) + + test("rejects local, cloud-preview, and malformed keys", () => { + expect(isHostedKey("ses_1")).toBe(false) + expect(isHostedKey("cloud:ses_1")).toBe(false) + expect(isHostedKey(undefined)).toBe(false) + expect(parseHostedKey("host:")).toBeUndefined() + expect(parseHostedKey("host:kilo-cloud")).toBeUndefined() + expect(parseHostedKey("host::ses_1")).toBeUndefined() + expect(parseHostedKey("host:kilo-cloud:")).toBeUndefined() + }) + + test("keeps colliding native ids distinct across providers", () => { + expect(hostedKey("alpha", "ses_same")).not.toBe(hostedKey("beta", "ses_same")) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/hosted-service.test.ts b/packages/kilo-vscode/tests/unit/hosted-service.test.ts new file mode 100644 index 000000000000..3283d8f38a33 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/hosted-service.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, test, afterEach } from "bun:test" +import { createKiloClient } from "@kilocode/sdk/v2/client" +import { HostedService } from "../../src/hosted/service" + +interface Call { + method: string + path: string + workspace: string | null + body?: unknown +} + +interface Reply { + status?: number + body: unknown +} + +function session(id = "ses_hosted") { + return { + id, + slug: id, + projectID: "project", + directory: "/cloud-agent/sessions/" + id, + title: "Hosted work", + version: "1", + time: { created: 1_700_000_000_000, updated: 1_700_000_100_000 }, + } +} + +/** + * A real local backend stand-in. The service must speak the same generated SDK + * contract the CLI exposes, so a fake HTTP server catches selector mistakes + * that a stubbed client would hide. + */ +function backend(handler: (call: Call, index: number) => Reply) { + const calls: Call[] = [] + const server = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url) + const call: Call = { + method: request.method, + path: url.pathname, + workspace: url.searchParams.get("workspace"), + body: request.method === "POST" ? await request.json().catch(() => undefined) : undefined, + } + calls.push(call) + const reply = handler(call, calls.length - 1) + return new Response(JSON.stringify(reply.body), { + status: reply.status ?? 200, + headers: { "content-type": "application/json" }, + }) + }, + }) + const client = createKiloClient({ baseUrl: server.url.href }) + const service = new HostedService(() => client) + return { server, calls, service } +} + +let stop: (() => void) | undefined +afterEach(() => { + stop?.() + stop = undefined +}) + +describe("hosted service", () => { + test("opens durable history without attaching or subscribing", async () => { + const ctx = backend((call) => { + if (call.path.endsWith("/message")) { + return { + body: { + items: [{ info: { id: "msg_1", sessionID: "ses_hosted", role: "user", time: { created: 1 } }, parts: [] }], + cursor: "next", + }, + } + } + if (call.path.startsWith("/kilocode/session-host/session/")) { + return { body: { id: { providerID: "kilo-cloud", sessionID: "ses_hosted" }, session: session() } } + } + return { status: 404, body: {} } + }) + stop = () => ctx.server.stop() + + const open = await ctx.service.open("kilo-cloud", "ses_hosted") + expect(open.key).toBe("host:kilo-cloud:ses_hosted") + expect(open.items).toHaveLength(1) + expect(open.cursor).toBe("next") + expect(ctx.calls.some((call) => call.path.endsWith("/attach"))).toBe(false) + expect(ctx.calls.some((call) => call.path === "/event")).toBe(false) + expect(ctx.calls.some((call) => call.path === "/session/status")).toBe(false) + }) + + test("prompt routes the native session through the attachment selector", async () => { + const ctx = backend((call) => { + if (call.path.endsWith("/attach")) { + return { + body: { + attachmentID: "wrk_att_1", + session: { providerID: "kilo-cloud", sessionID: "ses_hosted" }, + expiresAt: Date.now() + 60_000, + }, + } + } + if (call.path === "/session/ses_hosted/prompt_async") return { body: true } + return { status: 404, body: {} } + }) + stop = () => ctx.server.stop() + + ctx.service.remember("kilo-cloud", "ses_hosted") + await ctx.service.prompt("host:kilo-cloud:ses_hosted", { + messageID: "msg_1", + parts: [{ type: "text", text: "hello" }], + agent: "build", + model: { providerID: "p", modelID: "m" }, + }) + + const prompt = ctx.calls.find((call) => call.path === "/session/ses_hosted/prompt_async") + expect(prompt?.workspace).toBe("wrk_att_1") + expect(prompt?.method).toBe("POST") + }) + + test("one recoverable failure mints a new attachment and retries once", async () => { + let attaches = 0 + let reads = 0 + const ctx = backend((call) => { + if (call.path.endsWith("/attach")) { + attaches += 1 + return { + body: { + attachmentID: `wrk_att_old`, + session: { providerID: "kilo-cloud", sessionID: "ses_hosted" }, + expiresAt: Date.now() + 60_000, + }, + } + } + if (call.path === "/session/ses_hosted/message") { + reads += 1 + if (reads === 1) { + return { + status: 410, + body: { error: "KILO_SESSION_ATTACHMENT", code: "expired", message: "Session attachment has expired" }, + } + } + return { body: [] } + } + return { status: 404, body: {} } + }) + stop = () => ctx.server.stop() + + ctx.service.remember("kilo-cloud", "ses_hosted") + const result = await ctx.service.messages("host:kilo-cloud:ses_hosted", { limit: 10 }) + expect(result.data).toEqual([]) + expect(attaches).toBe(2) + expect(reads).toBe(2) + }) + + test("does not retry an unrecoverable failure", async () => { + let attaches = 0 + const ctx = backend((call) => { + if (call.path.endsWith("/attach")) { + attaches += 1 + return { + body: { + attachmentID: "wrk_att_1", + session: { providerID: "kilo-cloud", sessionID: "ses_hosted" }, + expiresAt: Date.now() + 60_000, + }, + } + } + return { + status: 400, + body: { error: "KILO_SESSION_ATTACHMENT", code: "invalid_request", message: "unsupported" }, + } + }) + stop = () => ctx.server.stop() + + ctx.service.remember("kilo-cloud", "ses_hosted") + await expect(ctx.service.messages("host:kilo-cloud:ses_hosted", { limit: 10 })).rejects.toBeDefined() + expect(attaches).toBe(1) + }) + + test("start forwards the caller's idempotency fields and keeps the hosted key", async () => { + const ctx = backend((call) => { + if (call.path !== "/kilocode/session-host/session") return { status: 404, body: {} } + return { + body: { + session: { providerID: "kilo-cloud", sessionID: "ses_hosted" }, + messageID: "msg_1", + delivery: "queued", + replayed: true, + }, + } + }) + stop = () => ctx.server.stop() + + const start = await ctx.service.start({ + repository: { type: "github", repo: "owner/repo" }, + prompt: "hello", + mode: "build", + model: "p/m", + operationKey: "11111111-1111-4111-8111-111111111111", + messageID: "msg_1", + }) + expect(start).toEqual({ + key: "host:kilo-cloud:ses_hosted", + providerID: "kilo-cloud", + sessionID: "ses_hosted", + replayed: true, + delivery: "queued", + }) + const body = ctx.calls[0]?.body as { operationKey?: string; messageID?: string } + expect(body.operationKey).toBe("11111111-1111-4111-8111-111111111111") + expect(body.messageID).toBe("msg_1") + }) + + test("keeps colliding native session ids on their own attachment", async () => { + const ctx = backend((call, index) => { + if (call.path.endsWith("/attach")) { + return { + body: { + // Per-call ids, so the assertions can prove each key keeps its own. + attachmentID: `wrk_att_${index + 1}`, + session: { providerID: "kilo-cloud", sessionID: "ses_same" }, + expiresAt: Date.now() + 60_000, + }, + } + } + return { body: [] } + }) + stop = () => ctx.server.stop() + + ctx.service.remember("alpha", "ses_same") + ctx.service.remember("beta", "ses_same") + await ctx.service.attach("host:alpha:ses_same") + await ctx.service.attach("host:beta:ses_same") + const alpha = ctx.service.attachment("host:alpha:ses_same")?.attachmentID + const beta = ctx.service.attachment("host:beta:ses_same")?.attachmentID + expect(alpha).toBe("wrk_att_1") + expect(beta).toBe("wrk_att_2") + expect(alpha).not.toBe(beta) + expect(ctx.calls.filter((call) => call.path.endsWith("/attach"))).toHaveLength(2) + }) + + test("detaches an attachment that lost the invalidation race instead of leaking it", async () => { + const gate = Promise.withResolvers() + const detached: string[] = [] + const service = new HostedService( + () => + ({ + sessionHost: { + attach: async () => { + await gate.promise + return { data: { attachmentID: "wrk_att_stale", expiresAt: Date.now() + 60_000 } } + }, + detach: async (input: { attachmentID: string }) => { + detached.push(input.attachmentID) + return { data: true } + }, + }, + }) as never, + ) + const key = service.remember("kilo-cloud", "ses_hosted") + + const pending = service.attach(key) + service.invalidate(key) + gate.resolve() + await pending.catch(() => undefined) + await Promise.resolve() + await Promise.resolve() + expect(detached).toEqual(["wrk_att_stale"]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/hosted-session-bridge.test.ts b/packages/kilo-vscode/tests/unit/hosted-session-bridge.test.ts new file mode 100644 index 000000000000..14865ea82201 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/hosted-session-bridge.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "bun:test" +import { createHostedBridge } from "../../webview-ui/src/context/hosted-session-bridge" +import type { ExtensionMessage } from "../../webview-ui/src/types/messages" + +type Loaded = Extract + +function loaded(key: string): Loaded { + return { + type: "hostedSessionLoaded", + key, + providerID: "kilo-cloud", + sessionID: key, + session: { id: key, title: key, time: { created: 1, updated: 2 } }, + title: key, + messages: [], + } +} + +function bridge(current: string | undefined, calls: string[]) { + return createHostedBridge({ + store: (() => undefined) as never, + loaded: () => calls.push("loaded"), + page: () => calls, + toolParts: () => calls, + isolate: (part) => part, + current: () => current, + focus: (key) => calls.push(`focus:${key}`), + draft: (key) => calls.push(`draft:${key}`), + loading: (value) => calls.push(`loading:${value}`), + clearDraft: () => calls.push("clearDraft"), + connected: () => true, + post: (message) => calls.push(`post:${message.type}`), + batch: (run) => run(), + }) +} + +describe("hosted session bridge", () => { + it("does not pull the user back when a late load is no longer the current session", () => { + const key = "host:kilo-cloud:ses_a" + const calls: string[] = [] + bridge("ses_other", calls).handle(loaded(key)) + + // Data is still written so a later selection is warm, but focus and the + // loading state belong to the session the user is actually on. + expect(calls).toContain("loaded") + expect(calls).not.toContain(`focus:${key}`) + expect(calls.some((call) => call.startsWith("loading:"))).toBe(false) + }) + + it("clears loading when the loaded session is still current", () => { + const key = "host:kilo-cloud:ses_a" + const calls: string[] = [] + bridge(key, calls).handle(loaded(key)) + + expect(calls).toContain("loading:false") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/hosted-wiring.test.ts b/packages/kilo-vscode/tests/unit/hosted-wiring.test.ts new file mode 100644 index 000000000000..101789c42820 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/hosted-wiring.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +const ROOT = path.resolve(import.meta.dir, "../..") +const read = (file: string) => fs.readFileSync(path.join(ROOT, file), "utf-8") + +/** + * Regressions that are cheap to verify statically and expensive to notice at + * runtime: a hosted prompt routed to a stale cloud preview, hosted sessions + * rendering outside the history panel region, and hosted streams that are + * never released. + */ +describe("hosted session wiring", () => { + it("clears the cloud preview when a hosted session is selected", () => { + const source = read("webview-ui/src/context/session.tsx") + const start = source.indexOf("function selectHostedSession") + expect(start).toBeGreaterThan(-1) + const body = source.slice(start, start + 400) + expect(body).toMatch(/setCloudPreviewId\(null\)/) + }) + + it("never falls back to a cloud preview for a hosted session", () => { + const source = read("webview-ui/src/context/session.tsx") + const guards = source.match(/origin === undefined && !sid\?\.startsWith\("host:"\)/g) ?? [] + expect(guards).toHaveLength(2) + }) + + it("renders the hosted panel outside the tab list", () => { + const source = read("webview-ui/src/components/history/HistoryView.tsx") + const tabs = source.indexOf('class="history-view-tabs"') + const hosted = source.indexOf('id="history-panel-hosted"') + expect(tabs).toBeGreaterThan(-1) + expect(hosted).toBeGreaterThan(-1) + const between = source.slice(tabs, hosted) + expect(between.includes('id="history-panel-cloud"')).toBe(true) + }) + + it("releases hosted subscriptions when a session is no longer open", () => { + const source = read("src/KiloProvider.ts") + const start = source.indexOf("private trackOpenSessions") + expect(start).toBeGreaterThan(-1) + const body = source.slice(start, start + 800) + expect(body).toMatch(/this\.releaseHosted\(key\)/) + }) + + it("preserves opened hosted tabs across complete local session reconciliation", () => { + const source = read("webview-ui/src/context/local-tabs.tsx") + const start = source.indexOf("const openHosted") + expect(start).toBeGreaterThan(-1) + const body = source.slice(start, start + 500) + expect(body).toMatch(/fresh\.add\(id\)/) + expect(body.indexOf("fresh.add(id)")).toBeLessThan(body.indexOf("openSessionTab")) + }) + + it("keeps provider and session discovery errors independent", () => { + const source = read("webview-ui/src/components/history/HostedSessionList.tsx") + expect(source).toMatch(/setProviderError\(message\.error \?\? ""\)/) + expect(source).toMatch(/setSessionError\(message\.error \?\? ""\)/) + expect(source).toMatch(/actionError\(\) \|\| providerError\(\) \|\| sessionError\(\)/) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts index 90e0e442da3b..7a0b771b572c 100644 --- a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts @@ -24,6 +24,7 @@ const PROMPT_UTILS_FILE = path.join(ROOT, "webview-ui/src/components/chat/prompt const PROMPT_FILE = path.join(ROOT, "webview-ui/src/components/chat/PromptInput.tsx") const KILOPROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts") const CLOUD_SESSION_FILE = path.join(ROOT, "src/kilo-provider/handlers/cloud-session.ts") +const CLOUD_FAILURE_FILE = path.join(ROOT, "webview-ui/src/context/cloud-session-failure.ts") const CONNECTION_SERVICE_FILE = path.join(ROOT, "src/services/cli-backend/connection-service.ts") function readFile(filePath: string): string { @@ -594,14 +595,16 @@ describe("Cloud import parts cleanup contract", () => { }) it("handleCloudSessionImportFailed prunes cloud parts and the synthetic session entries", () => { - // Implemented as a switch case inside handleExtensionMessage, not a - // standalone function, so search the source for the case body directly. - const idx = source.indexOf('case "cloudSessionImportFailed"') + // The cleanup lives in cloud-session-failure.ts so the session switch + // stays under the complexity cap. The key must remain the synthetic + // "cloud:" scope, not the raw preview id. + const cleanup = readFile(CLOUD_FAILURE_FILE) + const idx = cleanup.indexOf("cloudSessionImportFailed") expect(idx).toBeGreaterThan(-1) - const after = source.slice(idx, idx + 4000) - expect(after).toMatch(/pruneCloudOrphans\(failedKey\)/) - expect(after).toMatch(/delete sessions\[failedKey\]/) - expect(after).toMatch(/delete messages\[failedKey\]/) + const after = cleanup.slice(idx) + expect(after).toMatch(/deps\.prune\(key\)/) + expect(after).toMatch(/delete sessions\[key\]/) + expect(after).toMatch(/delete messages\[key\]/) }) it("handleCloudSessionImportFailed clears cloudPreviewId, currentSessionID, draftSessionID, and loading only when still on the failed cloud session", () => { @@ -627,13 +630,13 @@ describe("Cloud import parts cleanup contract", () => { // otherwise `cloudPreviewId() === message.cloudSessionId` would be // false even on the failing preview and the spinner would stick // until later navigation clears it. - const idx = source.indexOf('case "cloudSessionImportFailed"') + const idx = source.indexOf("const cloudFailureDeps") expect(idx).toBeGreaterThan(-1) - const after = source.slice(idx, idx + 4000) - expect(after).toMatch(/clearIfOn\(cloudPreviewId, \(\) => setLoading\(false\), message\.cloudSessionId\)/) - expect(after).toMatch(/clearIfOn\(cloudPreviewId, \(\) => setCloudPreviewId\(null\), message\.cloudSessionId\)/) - expect(after).toMatch(/clearIfOn\(currentSessionID, \(\) => setCurrentSessionID\(undefined\), failedKey\)/) - expect(after).toMatch(/clearIfOn\(draftSessionID, \(\) => setDraftSessionID\(undefined\), failedKey\)/) + const after = source.slice(idx, idx + 2000) + expect(after).toMatch(/clearIfOn\(cloudPreviewId, \(\) => setLoading\(false\), raw\)/) + expect(after).toMatch(/clearIfOn\(cloudPreviewId, \(\) => setCloudPreviewId\(null\), raw\)/) + expect(after).toMatch(/clearIfOn\(currentSessionID, \(\) => setCurrentSessionID\(undefined\), key\)/) + expect(after).toMatch(/clearIfOn\(draftSessionID, \(\) => setDraftSessionID\(undefined\), key\)/) expect(after).not.toMatch(/^\s*setLoading\(false\)\s*$/m) // Loading check must come before cloudPreviewId null in the case body. const loadIdx = after.indexOf("setLoading(false)") diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 2da2d6b4f234..8423badf4f39 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -422,6 +422,8 @@ const AppContent: Component = () => { onCleanup(() => window.removeEventListener("message", handler)) }) + const hostedCurrent = () => session.currentSessionID()?.startsWith("host:") === true + const handleSelectSession = (id: string) => { if (tabs) tabs.open(id) if (!tabs) session.selectSession(id) @@ -466,7 +468,7 @@ const AppContent: Component = () => { { onSelectSession={handleSelectSession} onShowHistory={() => setCurrentView("history")} onForkMessage={session.status() === "idle" ? handleForkMessage : undefined} - continueInWorktree + continueInWorktree={!hostedCurrent()} promptBoxId="sidebar:new-task" emptyState={emptyState} /> diff --git a/packages/kilo-vscode/webview-ui/src/components/history/HistoryView.tsx b/packages/kilo-vscode/webview-ui/src/components/history/HistoryView.tsx index 09978acddfa0..1efbddfcb223 100644 --- a/packages/kilo-vscode/webview-ui/src/components/history/HistoryView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/history/HistoryView.tsx @@ -10,9 +10,11 @@ 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 { useConfig } from "../../context/config" import { CloudImportDialog } from "../chat/CloudImportDialog" import SessionList from "./SessionList" import CloudSessionList from "./CloudSessionList" +import HostedSessionList from "./HostedSessionList" import type { SessionInfo } from "../../types/messages" interface HistoryViewProps { @@ -25,7 +27,7 @@ interface HistoryViewProps { rowActions?: (session: SessionInfo) => JSX.Element } -type Source = "local" | "cloud" | "worktree" +type Source = "local" | "cloud" | "hosted" | "worktree" const EMPTY_SESSION_IDS = new Set() @@ -34,21 +36,27 @@ const HistoryView: Component = (props) => { const dialog = useDialog() const session = useSession() const tabs = useLocalTabs() + const config = useConfig() const worktreeIds = () => props.worktreeSessionIds?.() + const hosted = () => config.settings().hostedSessions === true const [tab, setTab] = createSignal(worktreeIds() ? "worktree" : "local") let local: HTMLButtonElement | undefined let cloud: HTMLButtonElement | undefined + let hostedTab: HTMLButtonElement | undefined let worktree: HTMLButtonElement | undefined let localPanel: HTMLDivElement | undefined let cloudPanel: HTMLDivElement | undefined + let hostedPanel: HTMLDivElement | undefined let worktreePanel: HTMLDivElement | undefined createEffect(() => { if (tab() === "worktree" && !worktreeIds()) setTab("local") + if (tab() === "hosted" && !hosted()) setTab("local") }) createEffect(() => { - const panel = tab() === "local" ? localPanel : tab() === "cloud" ? cloudPanel : worktreePanel + const panel = + tab() === "local" ? localPanel : tab() === "cloud" ? cloudPanel : tab() === "hosted" ? hostedPanel : worktreePanel const frame = requestAnimationFrame(() => { panel @@ -78,7 +86,12 @@ const HistoryView: Component = (props) => { } function move(event: KeyboardEvent, current: Source) { - const sources: Source[] = worktreeIds() ? ["local", "cloud", "worktree"] : ["local", "cloud"] + const sources: Source[] = [ + "local", + "cloud", + ...(hosted() ? (["hosted"] as Source[]) : []), + ...(worktreeIds() ? (["worktree"] as Source[]) : []), + ] const index = sources.indexOf(current) const source = event.key === "Home" @@ -90,7 +103,16 @@ const HistoryView: Component = (props) => { : event.key === "ArrowRight" ? sources[(index + 1) % sources.length] : undefined - const next = source === "local" ? local : source === "cloud" ? cloud : source === "worktree" ? worktree : undefined + const next = + source === "local" + ? local + : source === "cloud" + ? cloud + : source === "hosted" + ? hostedTab + : source === "worktree" + ? worktree + : undefined if (!next) return event.preventDefault() next.focus() @@ -133,6 +155,23 @@ const HistoryView: Component = (props) => { > {language.t("session.tab.cloud")} + + + + + )} + + +
+ {language.t("common.requestFailed")} + +
+
+
+ + +
+ + + + + items={sessions()} + key={(s) => s.key} + filterKeys={["title"]} + onMove={announce} + onSelect={(s) => { + if (s) props.onSelectSession?.(s) + }} + search={{ placeholder: language.t("session.search.placeholder"), autofocus: true }} + emptyMessage={loading() ? language.t("common.loading") : language.t("hosted.empty")} + > + {(s) => ( + <> + + {s.title || "Untitled"} + + {formatRelativeDate(new Date(s.updated).toISOString())} + + )} + +
+ {notice()} +
+ + ) +} + +export default HostedSessionList diff --git a/packages/kilo-vscode/webview-ui/src/components/history/HostedStartDialog.tsx b/packages/kilo-vscode/webview-ui/src/components/history/HostedStartDialog.tsx new file mode 100644 index 000000000000..411ab765bf11 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/history/HostedStartDialog.tsx @@ -0,0 +1,99 @@ +import { Component, createSignal } from "solid-js" +import { Dialog } from "@kilocode/kilo-ui/dialog" +import { Button } from "@kilocode/kilo-ui/button" +import { TextField } from "@kilocode/kilo-ui/text-field" +import { useDialog } from "@kilocode/kilo-ui/context/dialog" +import { useLanguage } from "../../context/language" +import { useSession } from "../../context/session" +import { Identifier } from "../../utils/id" +import type { HostedStartSessionMessage } from "../../types/messages" + +interface HostedStartDialogProps { + onStart: (message: HostedStartSessionMessage) => void +} + +/** + * Starts one hosted session. The operation key and initial message id are + * minted once per dialog submission so retrying the same intent deduplicates + * instead of creating a second session. + */ +export const HostedStartDialog: Component = (props) => { + const language = useLanguage() + const dialog = useDialog() + const session = useSession() + + const selection = () => session.selected() + const defaultModel = () => { + const model = selection() + return model ? `${model.providerID}/${model.modelID}` : "" + } + + const [repository, setRepository] = createSignal("") + const [branch, setBranch] = createSignal("") + const [prompt, setPrompt] = createSignal("") + const [agent, setAgent] = createSignal(session.agents()[0]?.name ?? "build") + const [model, setModel] = createSignal(defaultModel()) + const [error, setError] = createSignal(undefined) + + function submit() { + const repo = repository().trim() + const value = prompt().trim() + const mode = agent().trim() + const name = model().trim() + if (!repo) { + setError(language.t("hosted.repository")) + return + } + if (!value) { + setError(language.t("hosted.prompt")) + return + } + if (!mode || !name) { + setError(`${language.t("hosted.agent")} / ${language.t("hosted.model")}`) + return + } + const isUrl = repo.includes("://") || repo.endsWith(".git") + props.onStart({ + type: "hostedStartSession", + ...(isUrl ? { repositoryType: "git" as const, url: repo } : { repositoryType: "github" as const, repo }), + ...(branch().trim() ? { branch: branch().trim() } : {}), + prompt: value, + mode, + model: name, + operationKey: crypto.randomUUID(), + messageID: Identifier.ascending("message"), + }) + dialog.close() + } + + return ( + +
+ + + + + +
+ + +
+
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx index 624ed8d080a8..c210b1724f72 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx @@ -251,6 +251,19 @@ const ExperimentalTab: Component = () => {
+ + applySetting("hostedSessions", checked, "experimental.hostedSessions")} + hideLabel + > + {language.t("settings.experimental.hostedSessions.title")} + + + + prune(key: string): void + clearPreview(rawID: string): void + clearCurrent(key: string): void + clearDraft(key: string): void + toast(error: string): void +} + +/** + * Cloud import failure cleanup stays separate from hosted session handling: + * the legacy `cloud:` preview is a different identity space and must not + * be cleared by a hosted attachment failure. + */ +export function handleCloudImportFailure(message: ExtensionMessage, deps: CloudFailureDeps): void { + if (message.type !== "cloudSessionImportFailed") return + const key = `cloud:${message.cloudSessionId}` + deps.prune(key) + deps.store( + "sessions", + produce((sessions) => { + delete sessions[key] + }), + ) + deps.store( + "messages", + produce((messages) => { + delete messages[key] + }), + ) + deps.store( + "toolParts", + produce((parts) => { + delete parts[key] + }), + ) + // cloudPreviewId stores the raw cloud session id, not the synthetic key. + deps.clearPreview(message.cloudSessionId) + deps.clearCurrent(key) + deps.clearDraft(key) + deps.toast(message.error) +} diff --git a/packages/kilo-vscode/webview-ui/src/context/config.tsx b/packages/kilo-vscode/webview-ui/src/context/config.tsx index 305060c3638e..108f162ebda4 100644 --- a/packages/kilo-vscode/webview-ui/src/context/config.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/config.tsx @@ -82,6 +82,7 @@ function loadedSettings(message: ExtensionMessage): Record | un return { "chat.shiftTabCyclesVariant": message.settings.shiftTabCyclesVariant, browserAutomation: message.settings.browserAutomation, + hostedSessions: message.settings.hostedSessions, agentManagerBrowserOpenLinksIn: message.settings.agentManagerBrowserOpenLinksIn, workspaceTrusted: message.settings.workspaceTrusted, } diff --git a/packages/kilo-vscode/webview-ui/src/context/hosted-session-bridge.ts b/packages/kilo-vscode/webview-ui/src/context/hosted-session-bridge.ts new file mode 100644 index 000000000000..bfb4bd5128b6 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/hosted-session-bridge.ts @@ -0,0 +1,71 @@ +import type { SetStoreFunction } from "solid-js/store" +import type { SessionStore } from "./session-store" +import type { ExtensionMessage, Message, Part } from "../types/messages" + +export interface HostedBridgeDeps { + store: SetStoreFunction + loaded(key: string): void + page(key: string, hasMore: boolean): void + toolParts(key: string, messages: Message[]): void + isolate(part: Part): Part + current(): string | undefined + focus(key: string): void + draft(key: string): void + loading(value: boolean): void + clearDraft(): void + connected(): boolean + post(message: { type: "hostedOpenSession"; providerID: string; sessionID: string }): void + batch(run: () => void): void +} + +type Loaded = Extract + +/** + * Bridges hosted session messages into the existing session store. + * + * Hosted sessions reuse the local renderer under a namespaced key, so nothing + * here needs a second transcript path; it only writes the same shapes the + * local `messagesLoaded` handler writes. + */ +export function createHostedBridge(deps: HostedBridgeDeps) { + function load(message: Loaded) { + deps.batch(() => { + deps.loaded(message.key) + deps.store("sessions", message.key, { + id: message.key, + title: message.title, + createdAt: new Date(message.session.time.created).toISOString(), + updatedAt: new Date(message.session.time.updated).toISOString(), + }) + deps.page(message.key, Boolean(message.cursor)) + deps.store("messages", message.key, message.messages) + for (const msg of message.messages) { + if (msg.parts && msg.parts.length > 0) deps.store("parts", msg.id, msg.parts.map(deps.isolate)) + } + deps.toolParts(message.key, message.messages) + // A late response must not pull the user back if they already switched + // away, nor clear another session's loading state. + if (deps.current() !== message.key) return + deps.loading(false) + }) + } + + function select(providerID: string, sessionID: string) { + if (!deps.connected()) { + console.warn("[Kilo New] Cannot select hosted session: not connected") + return + } + const key = `host:${providerID}:${sessionID}` + deps.clearDraft() + deps.focus(key) + deps.loading(true) + deps.post({ type: "hostedOpenSession", providerID, sessionID }) + } + + function handle(message: ExtensionMessage) { + if (message.type === "hostedSessionLoaded") load(message) + if (message.type === "hostedSessionFailed" && message.sessionID) deps.loading(false) + } + + return { handle, select } +} diff --git a/packages/kilo-vscode/webview-ui/src/context/local-tabs.tsx b/packages/kilo-vscode/webview-ui/src/context/local-tabs.tsx index 989e5509ec87..71884d62527e 100644 --- a/packages/kilo-vscode/webview-ui/src/context/local-tabs.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/local-tabs.tsx @@ -59,6 +59,7 @@ interface LocalTabsValue { isPinned: (id: string) => boolean togglePinned: (id: string) => void previewCloud: (id: string) => void + openHosted: (id: string) => void reorder: (from: string, to: string) => boolean move: (id: string, offset: -1 | 1) => number | undefined persist: () => void @@ -66,6 +67,9 @@ interface LocalTabsValue { const LocalTabsContext = createContext() +/** Hosted sessions are keyed `host::` (see src/hosted/identity.ts). */ +export const HOSTED_TAB_PREFIX = "host:" + const same = (left: string[], right: string[]) => left.length === right.length && left.every((id, i) => right[i] === id) export const LocalTabsProvider: ParentComponent = (props) => { @@ -182,6 +186,16 @@ export const LocalTabsProvider: ParentComponent = (props) => { const closeToRightTab = (id: string) => closeToRight(id, closeDeps) const rightTabs = (id: string) => closableRight(id, closeDeps) const previewCloud = (id: string) => setCloud(id) + const openHosted = (id: string) => { + if (!id.startsWith(HOSTED_TAB_PREFIX)) return + setCloud(undefined) + // Complete local session lists never contain hosted keys. Preserve this + // tab until the user closes it instead of reconciling it away. + fresh.add(id) + apply(openSessionTab(current(), id)) + focus(id) + persist() + } const reorder = (from: string, to: string) => { const next = reorderPinnedTabs(ids(), pinned(), from, to) if (!next) return false @@ -250,6 +264,12 @@ export const LocalTabsProvider: ParentComponent = (props) => { focus(next.active) return } + if (message.type === "hostedSessionStarted") { + fresh.add(message.key) + apply(openSessionTab(current(), message.key)) + focus(message.key) + return + } if (message.type === "cloudSessionImported") { const activate = cloud() === message.cloudSessionId fresh.add(message.session.id) @@ -297,6 +317,7 @@ export const LocalTabsProvider: ParentComponent = (props) => { isPinned, togglePinned, previewCloud, + openHosted, reorder, move, persist, diff --git a/packages/kilo-vscode/webview-ui/src/context/session-store.ts b/packages/kilo-vscode/webview-ui/src/context/session-store.ts new file mode 100644 index 000000000000..13fbc45cb98c --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/session-store.ts @@ -0,0 +1,34 @@ +import type { + Message, + ModelSelection, + ModelUsageMap, + Part, + SessionCloseReason, + SessionInfo, + SessionModelUsage, + TodoItem, + ToolPart, +} from "../types/messages" + +/** Store structure for messages and parts. */ +export interface SessionStore { + sessions: Record + messages: Record // sessionID -> messages + parts: Record // messageID -> parts + toolParts: Record // sessionID -> compact per-session tool index + todos: Record // sessionID -> todos + sessionOverrides: Record> // scope -> agent -> explicit model pick + agentSelections: Record // sessionID -> agent name + variantSelections: Record // session/agent scoped variant key -> variant name + recentModels: ModelSelection[] + favoriteModels: ModelSelection[] + modelUsageHistory: ModelUsageMap + modelUsage: Record +} + +export interface CloseState { + reason: SessionCloseReason + parentID?: string + eventID?: string + seen?: boolean +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session-types.ts b/packages/kilo-vscode/webview-ui/src/context/session-types.ts index 8bc90596e390..e03ee93a97d9 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-types.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-types.ts @@ -245,6 +245,7 @@ export interface SessionContextValue { // Cloud session preview cloudPreviewId: Accessor selectCloudSession: (cloudSessionId: string) => void + selectHostedSession: (providerID: string, sessionID: string) => void draftSessionID: Accessor setDraftSessionID: (id: string | undefined) => void userClearedSession: Accessor diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 21da128cc467..663f35aa6193 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -110,32 +110,13 @@ import { createModelSelector } from "./session-model-selector" import { createModelPreferences } from "./session-model-preferences" import { activities, blockedSessionIds, type Activity } from "../utils/session-activity" import { createTiming, running, type Timing } from "./session-timing" +import { createHostedBridge } from "./hosted-session-bridge" +import { handleCloudImportFailure, type CloudFailureDeps } from "./cloud-session-failure" +import type { CloseState, SessionStore } from "./session-store" import type { SessionContextValue } from "./session-types" const RECENT_LIMIT = 5 const MESSAGE_PAGE_LIMIT = 80 -// Store structure for messages and parts -interface SessionStore { - sessions: Record - messages: Record // sessionID -> messages - parts: Record // messageID -> parts - toolParts: Record // sessionID -> compact per-session tool index - todos: Record // sessionID -> todos - sessionOverrides: Record> // scope -> agent -> explicit model pick - agentSelections: Record // sessionID -> agent name - variantSelections: Record // session/agent scoped variant key -> variant name - recentModels: ModelSelection[] - favoriteModels: ModelSelection[] - modelUsageHistory: ModelUsageMap - modelUsage: Record -} - -interface CloseState { - reason: SessionCloseReason - parentID?: string - eventID?: string - seen?: boolean -} export const SessionContext = createContext() @@ -894,6 +875,8 @@ export const SessionProvider: ParentComponent = (props) => { if (handleStreamMessage(message)) return handleCommandCompletion(message) handleResumeResult(message) + hosted.handle(message) + handleCloudImportFailure(message, cloudFailureDeps) cah.handleMessage(message) switch (message.type) { case "sessionCreated": @@ -1010,42 +993,6 @@ export const SessionProvider: ParentComponent = (props) => { handleCloudSessionImported(message.cloudSessionId, message.session) break - case "cloudSessionImportFailed": { - const failedKey = `cloud:${message.cloudSessionId}` - pruneCloudOrphans(failedKey) - setStore( - "sessions", - produce((sessions) => { - delete sessions[failedKey] - }), - ) - setStore( - "messages", - produce((messages) => { - delete messages[failedKey] - }), - ) - setStore( - "toolParts", - produce((toolParts) => { - delete toolParts[failedKey] - }), - ) - // cloudPreviewId stores the raw cloud session id (see selectCloudSession), - // not the synthetic "cloud:" key used for session/draft ids. - clearIfOn(cloudPreviewId, () => setLoading(false), message.cloudSessionId) - clearIfOn(cloudPreviewId, () => setCloudPreviewId(null), message.cloudSessionId) - clearIfOn(currentSessionID, () => setCurrentSessionID(undefined), failedKey) - clearIfOn(draftSessionID, () => setDraftSessionID(undefined), failedKey) - showToast({ - variant: "error", - title: language.t("session.cloud.import.failed") ?? "Failed to import cloud session", - description: message.error, - }) - console.error("[Kilo New] Cloud session import failed:", message.error) - break - } - case "worktreeStatsLoaded": setWorktreeStats({ files: message.files, additions: message.additions, deletions: message.deletions }) break @@ -1210,6 +1157,50 @@ export const SessionProvider: ParentComponent = (props) => { setTools(sessionID, tools, mode) } + const hosted = createHostedBridge({ + store: setStore, + loaded: (key) => + setLoaded((prev) => { + if (prev.has(key)) return prev + const next = new Set(prev) + next.add(key) + return next + }), + page: (key, hasMore) => patchPage(key, { hasMore, lastMutation: "replace" }), + toolParts: (key, messages) => rebuildToolParts(key, messages), + isolate: (part) => isolate(part), + current: () => currentSessionID(), + focus: (key) => { + setCurrentSessionID(key) + setDraftSessionID(key) + }, + draft: (key) => setDraftSessionID(key), + loading: (value) => setLoading(value), + clearDraft: () => agentDrafts.prune(draftSessionID()), + connected: () => server.isConnected(), + post: (message) => vscode.postMessage(message), + batch, + }) + + const cloudFailureDeps: CloudFailureDeps = { + store: setStore, + prune: (key) => pruneCloudOrphans(key), + clearPreview: (raw) => { + clearIfOn(cloudPreviewId, () => setLoading(false), raw) + clearIfOn(cloudPreviewId, () => setCloudPreviewId(null), raw) + }, + clearCurrent: (key) => clearIfOn(currentSessionID, () => setCurrentSessionID(undefined), key), + clearDraft: (key) => clearIfOn(draftSessionID, () => setDraftSessionID(undefined), key), + toast: (error) => { + showToast({ + variant: "error", + title: language.t("session.cloud.import.failed") ?? "Failed to import cloud session", + description: error, + }) + console.error("[Kilo New] Cloud session import failed:", error) + }, + } + function patchToolPart(sessionID: string | undefined, messageID: string, part: Part) { const sid = sessionID ?? part.sessionID if (!sid) return @@ -2227,7 +2218,7 @@ export const SessionProvider: ParentComponent = (props) => { recordModelUsage(selection.providerID, selection.modelID) const preview = sid?.startsWith("cloud:") ? sid.slice("cloud:".length) - : origin === undefined + : origin === undefined && !sid?.startsWith("host:") ? cloudPreviewId() : null if (preview) { @@ -2327,7 +2318,7 @@ export const SessionProvider: ParentComponent = (props) => { // Cloud previews need import-then-command; post importAndSend with command metadata const preview = sid?.startsWith("cloud:") ? sid.slice("cloud:".length) - : origin === undefined + : origin === undefined && !sid?.startsWith("host:") ? cloudPreviewId() : null if (preview) { @@ -2685,6 +2676,15 @@ export const SessionProvider: ParentComponent = (props) => { vscode.postMessage({ type: "requestCloudSessionData", sessionId: cloudSessionId }) } + function selectHostedSession(providerID: string, sessionID: string) { + setUserClearedSession(false) + // A hosted key is not a cloud preview. Clear it, otherwise the prompt + // fallback would route the next send to a previously previewed cloud + // session instead of the hosted one. + setCloudPreviewId(null) + hosted.select(providerID, sessionID) + } + function deleteSession(id: string) { if (!server.isConnected()) { console.warn("[Kilo New] Cannot delete session: not connected") @@ -3071,6 +3071,7 @@ export const SessionProvider: ParentComponent = (props) => { unsyncSession, cloudPreviewId, selectCloudSession, + selectHostedSession, draftSessionID, setDraftSessionID, userClearedSession, diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 97f9c4db310e..fa750df58f94 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -580,6 +580,17 @@ export const dict = { "session.tabs.switcher.scheduled": "مجدولة", "session.tabs.pinHint": "Shift+النقر للتثبيت أو إلغاء التثبيت", "session.tab.local": "محلي", + "session.tab.hosted": "مستضافة", + "hosted.empty": "لا توجد جلسات مستضافة", + "hosted.start": "بدء جلسة مستضافة", + "hosted.repository": "المستودع", + "hosted.branch": "الفرع (اختياري)", + "hosted.prompt": "الموجّه", + "hosted.agent": "الوكيل", + "hosted.model": "النموذج", + "settings.experimental.hostedSessions.title": "جلسات سحابية مستضافة", + "settings.experimental.hostedSessions.description": + "اكتشف جلسات Kilo Cloud المستضافة وابدأها وتفاعل معها من VS Code.", "session.tab.cloud": "السحابة", "session.tab.worktree": "شجرة العمل", "session.cloud.repoOnly": "هذا المستودع فقط", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index b8c2e64dd841..d371f89c2a57 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -594,6 +594,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Agendado", "session.tabs.pinHint": "Shift+clique para fixar ou desafixar", "session.tab.local": "Local", + "session.tab.hosted": "Hospedadas", + "hosted.empty": "Nenhuma sessão hospedada", + "hosted.start": "Iniciar sessão hospedada", + "hosted.repository": "Repositório", + "hosted.branch": "Branch (opcional)", + "hosted.prompt": "Prompt", + "hosted.agent": "Agente", + "hosted.model": "Modelo", + "settings.experimental.hostedSessions.title": "Sessões na nuvem hospedadas", + "settings.experimental.hostedSessions.description": + "Descubra, inicie e interaja com sessões hospedadas do Kilo Cloud no VS Code.", "session.tab.cloud": "Nuvem", "session.tab.worktree": "Árvore de trabalho", "session.cloud.repoOnly": "Apenas este repositório", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 51c049a7a177..950b4ab2662f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -635,6 +635,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Zakazano", "session.tabs.pinHint": "Shift+klik za zakačiti ili otkačiti", "session.tab.local": "Lokalno", + "session.tab.hosted": "Hostovane", + "hosted.empty": "Nema hostovanih sesija", + "hosted.start": "Pokreni hostovanu sesiju", + "hosted.repository": "Repozitorij", + "hosted.branch": "Grana (opcionalno)", + "hosted.prompt": "Prompt", + "hosted.agent": "Agent", + "hosted.model": "Model", + "settings.experimental.hostedSessions.title": "Hostovane cloud sesije", + "settings.experimental.hostedSessions.description": + "Otkrijte, pokrenite i koristite hostovane Kilo Cloud sesije iz VS Code.", "session.tab.cloud": "Oblak", "session.tab.worktree": "Radno stablo", "session.cloud.repoOnly": "Samo ovaj repozitorij", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 400be71bcd0c..0de7dd1b3e62 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -632,6 +632,16 @@ export const dict = { "session.tabs.switcher.scheduled": "Planlagt", "session.tabs.pinHint": "Skift+klik for at fastgøre eller frigøre", "session.tab.local": "Lokal", + "session.tab.hosted": "Hostede", + "hosted.empty": "Ingen hostede sessioner", + "hosted.start": "Start hostet session", + "hosted.repository": "Repository", + "hosted.branch": "Branch (valgfrit)", + "hosted.prompt": "Prompt", + "hosted.agent": "Agent", + "hosted.model": "Model", + "settings.experimental.hostedSessions.title": "Hostede cloud-sessioner", + "settings.experimental.hostedSessions.description": "Find, start og brug hostede Kilo Cloud-sessioner fra VS Code.", "session.tab.cloud": "Sky", "session.tab.worktree": "Arbejdstræ", "session.cloud.repoOnly": "Kun dette repository", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 66f683b35ffc..e49647ace2cf 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -644,6 +644,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Geplant", "session.tabs.pinHint": "Umschalt+Klick zum Anheften oder Lösen", "session.tab.local": "Lokal", + "session.tab.hosted": "Gehostet", + "hosted.empty": "Keine gehosteten Sitzungen", + "hosted.start": "Gehostete Sitzung starten", + "hosted.repository": "Repository", + "hosted.branch": "Branch (optional)", + "hosted.prompt": "Prompt", + "hosted.agent": "Agent", + "hosted.model": "Modell", + "settings.experimental.hostedSessions.title": "Gehostete Cloud-Sitzungen", + "settings.experimental.hostedSessions.description": + "Gehostete Kilo Cloud-Sitzungen in VS Code entdecken, starten und nutzen.", "session.tab.cloud": "Cloud", "session.tab.worktree": "Arbeitsbaum", "session.cloud.repoOnly": "Nur dieses Repository", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index dd4af1679ab4..18133ef71309 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -542,6 +542,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Scheduled", "session.tabs.pinHint": "Shift+click to pin or unpin", "session.tab.local": "Local", + "session.tab.hosted": "Hosted", + "hosted.empty": "No hosted sessions", + "hosted.start": "Start hosted session", + "hosted.repository": "Repository", + "hosted.branch": "Branch (optional)", + "hosted.prompt": "Prompt", + "hosted.agent": "Agent", + "hosted.model": "Model", + "settings.experimental.hostedSessions.title": "Hosted cloud sessions", + "settings.experimental.hostedSessions.description": + "Discover, start, and interact with Kilo Cloud hosted sessions from VS Code.", "session.tab.cloud": "Cloud", "session.tab.worktree": "Worktree", "session.cloud.repoOnly": "Only this repository", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index cc7172d002c0..329916a14ab5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -637,6 +637,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Programado", "session.tabs.pinHint": "Mayús+clic para fijar o dejar de fijar", "session.tab.local": "Local", + "session.tab.hosted": "Alojadas", + "hosted.empty": "No hay sesiones alojadas", + "hosted.start": "Iniciar sesión alojada", + "hosted.repository": "Repositorio", + "hosted.branch": "Rama (opcional)", + "hosted.prompt": "Prompt", + "hosted.agent": "Agente", + "hosted.model": "Modelo", + "settings.experimental.hostedSessions.title": "Sesiones en la nube alojadas", + "settings.experimental.hostedSessions.description": + "Descubre, inicia e interactúa con sesiones alojadas de Kilo Cloud desde VS Code.", "session.tab.cloud": "Nube", "session.tab.worktree": "Árbol de trabajo", "session.cloud.repoOnly": "Solo este repositorio", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index 0df28b5b92a2..05cd1df4fc52 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -548,6 +548,17 @@ export const dict = { "session.tabs.switcher.scheduled": "زمان‌بندی‌شده", "session.tabs.pinHint": "Shift+کلیک برای سنجاق کردن یا لغو آن", "session.tab.local": "محلی", + "session.tab.hosted": "میزبانی‌شده", + "hosted.empty": "هیچ نشست میزبانی‌شده‌ای وجود ندارد", + "hosted.start": "شروع نشست میزبانی‌شده", + "hosted.repository": "مخزن", + "hosted.branch": "شاخه (اختیاری)", + "hosted.prompt": "اعلان", + "hosted.agent": "عامل", + "hosted.model": "مدل", + "settings.experimental.hostedSessions.title": "نشست‌های ابری میزبانی‌شده", + "settings.experimental.hostedSessions.description": + "نشست‌های میزبانی‌شده Kilo Cloud را در VS Code کشف، شروع و استفاده کنید.", "session.tab.cloud": "Cloud", "session.tab.worktree": "Worktree", "session.cloud.repoOnly": "فقط این مخزن", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 3d39475ce5b5..7e9ce4433f31 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -640,6 +640,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Programmé", "session.tabs.pinHint": "Maj+clic pour épingler ou détacher", "session.tab.local": "Local", + "session.tab.hosted": "Hébergées", + "hosted.empty": "Aucune session hébergée", + "hosted.start": "Démarrer une session hébergée", + "hosted.repository": "Dépôt", + "hosted.branch": "Branche (facultatif)", + "hosted.prompt": "Invite", + "hosted.agent": "Agent", + "hosted.model": "Modèle", + "settings.experimental.hostedSessions.title": "Sessions cloud hébergées", + "settings.experimental.hostedSessions.description": + "Découvrez, démarrez et utilisez des sessions hébergées Kilo Cloud depuis VS Code.", "session.tab.cloud": "Cloud", "session.tab.worktree": "Arbre de travail", "session.cloud.repoOnly": "Uniquement ce dépôt", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index f1bc2f0a44df..1ba97d3fecf3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -466,6 +466,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Programmato", "session.tabs.pinHint": "Shift+clic per fissare o sbloccare", "session.tab.local": "Locale", + "session.tab.hosted": "Ospitate", + "hosted.empty": "Nessuna sessione ospitata", + "hosted.start": "Avvia sessione ospitata", + "hosted.repository": "Repository", + "hosted.branch": "Branch (facoltativo)", + "hosted.prompt": "Prompt", + "hosted.agent": "Agente", + "hosted.model": "Modello", + "settings.experimental.hostedSessions.title": "Sessioni cloud ospitate", + "settings.experimental.hostedSessions.description": + "Scopri, avvia e interagisci con le sessioni ospitate di Kilo Cloud da VS Code.", "session.tab.cloud": "Cloud", "session.tab.worktree": "Albero di lavoro", "session.cloud.repoOnly": "Solo questa repository", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index b720b75a06b7..536a2b25b83b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -628,6 +628,17 @@ export const dict = { "session.tabs.switcher.scheduled": "予約済み", "session.tabs.pinHint": "Shift+クリックで固定または固定解除", "session.tab.local": "ローカル", + "session.tab.hosted": "ホスト済み", + "hosted.empty": "ホストされたセッションはありません", + "hosted.start": "ホストされたセッションを開始", + "hosted.repository": "リポジトリ", + "hosted.branch": "ブランチ(任意)", + "hosted.prompt": "プロンプト", + "hosted.agent": "エージェント", + "hosted.model": "モデル", + "settings.experimental.hostedSessions.title": "ホストされたクラウドセッション", + "settings.experimental.hostedSessions.description": + "VS Code で Kilo Cloud のホストされたセッションを検出、開始、操作します。", "session.tab.cloud": "クラウド", "session.tab.worktree": "ワークツリー", "session.cloud.repoOnly": "このリポジトリのみ", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 42bd484af66b..8985e212b6e7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -588,6 +588,17 @@ export const dict = { "session.tabs.switcher.scheduled": "예약됨", "session.tabs.pinHint": "Shift+클릭으로 고정하거나 고정 해제", "session.tab.local": "로컬", + "session.tab.hosted": "호스팅됨", + "hosted.empty": "호스팅된 세션이 없습니다", + "hosted.start": "호스팅된 세션 시작", + "hosted.repository": "저장소", + "hosted.branch": "브랜치(선택 사항)", + "hosted.prompt": "프롬프트", + "hosted.agent": "에이전트", + "hosted.model": "모델", + "settings.experimental.hostedSessions.title": "호스팅된 클라우드 세션", + "settings.experimental.hostedSessions.description": + "VS Code에서 Kilo Cloud 호스팅 세션을 찾아 시작하고 상호 작용합니다.", "session.tab.cloud": "클라우드", "session.tab.worktree": "작업 트리", "session.cloud.repoOnly": "이 저장소만", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 4158ee5e6c00..a323b3e6b850 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -585,6 +585,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Gepland", "session.tabs.pinHint": "Shift+klik om vast te maken of los te maken", "session.tab.local": "Lokaal", + "session.tab.hosted": "Gehost", + "hosted.empty": "Geen gehoste sessies", + "hosted.start": "Gehoste sessie starten", + "hosted.repository": "Repository", + "hosted.branch": "Branch (optioneel)", + "hosted.prompt": "Prompt", + "hosted.agent": "Agent", + "hosted.model": "Model", + "settings.experimental.hostedSessions.title": "Gehoste cloudsessies", + "settings.experimental.hostedSessions.description": + "Ontdek, start en gebruik gehoste Kilo Cloud-sessies vanuit VS Code.", "session.tab.cloud": "Cloud", "session.tab.worktree": "Werkboom", "session.cloud.repoOnly": "Alleen deze repository", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 24b7542e0ce6..cf6c6e211d2a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -595,6 +595,16 @@ export const dict = { "session.tabs.switcher.scheduled": "Planlagt", "session.tabs.pinHint": "Shift+klikk for å feste eller løsne", "session.tab.local": "Lokal", + "session.tab.hosted": "Hostede", + "hosted.empty": "Ingen hostede økter", + "hosted.start": "Start hostet økt", + "hosted.repository": "Repository", + "hosted.branch": "Branch (valgfritt)", + "hosted.prompt": "Prompt", + "hosted.agent": "Agent", + "hosted.model": "Modell", + "settings.experimental.hostedSessions.title": "Hostede skyøkter", + "settings.experimental.hostedSessions.description": "Oppdag, start og bruk hostede Kilo Cloud-økter fra VS Code.", "session.tab.cloud": "Sky", "session.tab.worktree": "Arbeidstre", "session.cloud.repoOnly": "Kun dette repositoriet", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index d83ade6d9992..1eaae255046c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -591,6 +591,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Zaplanowano", "session.tabs.pinHint": "Shift+kliknij, aby przypiąć lub odpiąć", "session.tab.local": "Lokalny", + "session.tab.hosted": "Hostowane", + "hosted.empty": "Brak sesji hostowanych", + "hosted.start": "Rozpocznij sesję hostowaną", + "hosted.repository": "Repozytorium", + "hosted.branch": "Gałąź (opcjonalnie)", + "hosted.prompt": "Prompt", + "hosted.agent": "Agent", + "hosted.model": "Model", + "settings.experimental.hostedSessions.title": "Hostowane sesje w chmurze", + "settings.experimental.hostedSessions.description": + "Odkrywaj, uruchamiaj i korzystaj z hostowanych sesji Kilo Cloud w VS Code.", "session.tab.cloud": "Chmura", "session.tab.worktree": "Drzewo robocze", "session.cloud.repoOnly": "Tylko to repozytorium", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 2c92db65005c..5a655bd56377 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -630,6 +630,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Запланировано", "session.tabs.pinHint": "Shift+щелчок, чтобы закрепить или открепить", "session.tab.local": "Локальный", + "session.tab.hosted": "Облачные", + "hosted.empty": "Нет облачных сессий", + "hosted.start": "Запустить облачную сессию", + "hosted.repository": "Репозиторий", + "hosted.branch": "Ветка (необязательно)", + "hosted.prompt": "Запрос", + "hosted.agent": "Агент", + "hosted.model": "Модель", + "settings.experimental.hostedSessions.title": "Облачные сессии", + "settings.experimental.hostedSessions.description": + "Обнаруживайте, запускайте и используйте облачные сессии Kilo Cloud в VS Code.", "session.tab.cloud": "Облако", "session.tab.worktree": "Рабочее дерево", "session.cloud.repoOnly": "Только этот репозиторий", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 128e875332c6..bc07cfe6932a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -625,6 +625,17 @@ export const dict = { "session.tabs.switcher.scheduled": "ตั้งเวลาไว้", "session.tabs.pinHint": "Shift+คลิกเพื่อปักหมุดหรือเลิกปักหมุด", "session.tab.local": "ในเครื่อง", + "session.tab.hosted": "โฮสต์", + "hosted.empty": "ไม่มีเซสชันที่โฮสต์", + "hosted.start": "เริ่มเซสชันที่โฮสต์", + "hosted.repository": "ที่เก็บโค้ด", + "hosted.branch": "สาขา (ไม่บังคับ)", + "hosted.prompt": "พรอมต์", + "hosted.agent": "เอเจนต์", + "hosted.model": "โมเดล", + "settings.experimental.hostedSessions.title": "เซสชันคลาวด์ที่โฮสต์", + "settings.experimental.hostedSessions.description": + "ค้นหา เริ่ม และโต้ตอบกับเซสชันที่โฮสต์ของ Kilo Cloud จาก VS Code", "session.tab.cloud": "คลาวด์", "session.tab.worktree": "เวิร์กทรี Git", "session.cloud.repoOnly": "เฉพาะรีโพซิทอรีนี้", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 89c313cd720d..9f7f36572a70 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -580,6 +580,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Zamanlandı", "session.tabs.pinHint": "Sabitlemek veya kaldırmak için Shift+tıkla", "session.tab.local": "Local", + "session.tab.hosted": "Barındırılan", + "hosted.empty": "Barındırılan oturum yok", + "hosted.start": "Barındırılan oturum başlat", + "hosted.repository": "Depo", + "hosted.branch": "Dal (isteğe bağlı)", + "hosted.prompt": "İstem", + "hosted.agent": "Aracı", + "hosted.model": "Model", + "settings.experimental.hostedSessions.title": "Barındırılan bulut oturumları", + "settings.experimental.hostedSessions.description": + "VS Code'dan Kilo Cloud barındırılan oturumlarını keşfedin, başlatın ve kullanın.", "session.tab.cloud": "Cloud", "session.tab.worktree": "Çalışma ağacı", "session.cloud.repoOnly": "Yalnızca bu depo", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 3938768ddd5b..05c7246d6223 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -585,6 +585,17 @@ export const dict = { "session.tabs.switcher.scheduled": "Заплановано", "session.tabs.pinHint": "Shift+клік, щоб закріпити або відкріпити", "session.tab.local": "Локальний", + "session.tab.hosted": "Хмарні", + "hosted.empty": "Немає хмарних сесій", + "hosted.start": "Запустити хмарну сесію", + "hosted.repository": "Репозиторій", + "hosted.branch": "Гілка (необов’язково)", + "hosted.prompt": "Запит", + "hosted.agent": "Агент", + "hosted.model": "Модель", + "settings.experimental.hostedSessions.title": "Хмарні сесії", + "settings.experimental.hostedSessions.description": + "Знаходьте, запускайте та використовуйте хмарні сесії Kilo Cloud у VS Code.", "session.tab.cloud": "Хмарний", "session.tab.worktree": "Робоче дерево", "session.cloud.repoOnly": "Лише цей репозиторій", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 9e553948765c..207515ecf607 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -606,6 +606,16 @@ export const dict = { "session.tabs.switcher.scheduled": "已计划", "session.tabs.pinHint": "按住 Shift 点击可固定或取消固定", "session.tab.local": "本地", + "session.tab.hosted": "托管", + "hosted.empty": "没有托管的会话", + "hosted.start": "启动托管会话", + "hosted.repository": "仓库", + "hosted.branch": "分支(可选)", + "hosted.prompt": "提示词", + "hosted.agent": "代理", + "hosted.model": "模型", + "settings.experimental.hostedSessions.title": "托管的云端会话", + "settings.experimental.hostedSessions.description": "在 VS Code 中发现、启动和交互 Kilo Cloud 托管的会话。", "session.tab.cloud": "云端", "session.tab.worktree": "工作树", "session.cloud.repoOnly": "仅此仓库", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index a696cb09219c..87b0b1bf76a2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -566,6 +566,16 @@ export const dict = { "session.tabs.switcher.scheduled": "已排程", "session.tabs.pinHint": "按住 Shift 點擊可釘選或取消釘選", "session.tab.local": "本機", + "session.tab.hosted": "託管", + "hosted.empty": "沒有託管的會話", + "hosted.start": "啟動託管會話", + "hosted.repository": "儲存庫", + "hosted.branch": "分支(選用)", + "hosted.prompt": "提示詞", + "hosted.agent": "代理", + "hosted.model": "模型", + "settings.experimental.hostedSessions.title": "託管的雲端會話", + "settings.experimental.hostedSessions.description": "在 VS Code 中探索、啟動並互動 Kilo Cloud 託管的會話。", "session.tab.cloud": "雲端", "session.tab.worktree": "工作樹", "session.cloud.repoOnly": "僅此儲存庫", diff --git a/packages/kilo-vscode/webview-ui/src/styles/history.css b/packages/kilo-vscode/webview-ui/src/styles/history.css index d8fb48587742..1868a17b4b58 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/history.css +++ b/packages/kilo-vscode/webview-ui/src/styles/history.css @@ -8,7 +8,8 @@ /* Session List Items */ .session-list [data-slot="list-item-title"], -.cloud-session-list [data-slot="list-item-title"] { +.cloud-session-list [data-slot="list-item-title"], +.hosted-session-list [data-slot="list-item-title"] { flex: 1; min-width: 0; overflow: hidden; @@ -18,7 +19,8 @@ } .session-list [data-slot="list-item-description"], -.cloud-session-list [data-slot="list-item-description"] { +.cloud-session-list [data-slot="list-item-description"], +.hosted-session-list [data-slot="list-item-description"] { flex-shrink: 0; margin-left: 8px; font-size: var(--kilo-font-size-12); @@ -177,6 +179,30 @@ body.vscode-light min-height: 0; } +/* Hosted Session List (inside History View) */ +.hosted-session-list { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.hosted-session-actions { + display: flex; + gap: 6px; + padding: 8px 10px; +} + +.hosted-session-notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + font-size: var(--kilo-font-size-12); + color: var(--text-weak, var(--vscode-descriptionForeground)); +} + /* Make the list search wrapper wrap so the repo filter appears below search */ .cloud-session-list [data-slot="list-search-wrapper"] { flex-wrap: wrap; diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index d22972129c56..f821ca662441 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -324,6 +324,75 @@ export interface CloudSessionDataLoadedMessage { messages: Message[] } +/** Provider-neutral availability, independent of any session's turn state. */ +export interface HostedProviderStatus { + id: string + default: boolean + availability: + | { state: "available" } + | { state: "unavailable"; reason: "signed_out" | "rejected" | "unreachable"; message: string } + transport: + | { state: "unknown" } + | { state: "reachable"; checkedAt: number } + | { state: "unreachable"; checkedAt: number; message: string } +} + +export interface HostedSessionSummary { + key: string + providerID: string + sessionID: string + title: string + created: number + updated: number + directory?: string +} + +export interface HostedProvidersLoadedMessage { + type: "hostedProvidersLoaded" + providers: HostedProviderStatus[] + error?: string +} + +export interface HostedSessionsLoadedMessage { + type: "hostedSessionsLoaded" + sessions: HostedSessionSummary[] + error?: string +} + +export interface HostedSessionLoadedMessage { + type: "hostedSessionLoaded" + key: string + providerID: string + sessionID: string + session: { id: string; title: string; time: { created: number; updated: number } } + title: string + messages: Message[] + cursor?: string + omitted?: number + mode?: MessageLoadMode +} + +export interface HostedSessionStartedMessage { + type: "hostedSessionStarted" + key: string + providerID: string + sessionID: string + replayed: boolean + delivery: "sent" | "queued" +} + +export interface HostedSessionFailedMessage { + type: "hostedSessionFailed" + error: string + code?: string + sessionID?: string +} + +export interface HostedSessionDetachedMessage { + type: "hostedSessionDetached" + key: string +} + export interface CloudSessionImportedMessage { type: "cloudSessionImported" cloudSessionId: string @@ -525,6 +594,7 @@ export interface ChatSettingsLoadedMessage { settings: { shiftTabCyclesVariant: boolean browserAutomation: boolean + hostedSessions: boolean agentManagerBrowserOpenLinksIn: "external" | "integrated" workspaceTrusted: boolean } @@ -1844,6 +1914,12 @@ export type ExtensionMessage = | MessageCreatedMessage | SessionsLoadedMessage | CloudSessionsLoadedMessage + | HostedProvidersLoadedMessage + | HostedSessionsLoadedMessage + | HostedSessionLoadedMessage + | HostedSessionStartedMessage + | HostedSessionFailedMessage + | HostedSessionDetachedMessage | GitRemoteUrlLoadedMessage | ActionMessage | ProfileDataMessage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index cebe0e5a498e..38b7d96086c0 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -138,6 +138,41 @@ export interface RequestCloudSessionsMessage { gitUrl?: string } +export interface RequestHostedProvidersMessage { + type: "requestHostedProviders" +} + +export interface RequestHostedSessionsMessage { + type: "requestHostedSessions" + limit?: number +} + +export interface HostedOpenSessionMessage { + type: "hostedOpenSession" + providerID: string + sessionID: string + limit?: number +} + +export interface HostedStartSessionMessage { + type: "hostedStartSession" + repositoryType?: "github" | "gitlab" | "git" + repo?: string + url?: string + branch?: string + prompt: string + mode: string + model: string + organizationID?: string + operationKey: string + messageID: string +} + +export interface HostedDetachSessionMessage { + type: "hostedDetachSession" + key: string +} + export interface RequestGitRemoteUrlMessage { type: "requestGitRemoteUrl" } @@ -1765,6 +1800,11 @@ export type WebviewMessage = | LoadSessionsRequest | RequestSessionModelUsageMessage | RequestCloudSessionsMessage + | RequestHostedProvidersMessage + | RequestHostedSessionsMessage + | HostedOpenSessionMessage + | HostedStartSessionMessage + | HostedDetachSessionMessage | RequestGitRemoteUrlMessage | LoginRequest | LogoutRequest