Merge remote-tracking branch 'origin/main' into generated-flame

# Conflicts:
#	packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt
This commit is contained in:
kirillk
2026-06-03 09:20:23 -04:00
87 changed files with 1871 additions and 322 deletions
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/kilo-ui": patch
---
Use a brain circuit icon for free-model data collection disclosures.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Show the animated Kilo logo while the console and dashboard finish loading.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Keep post-compaction tool calls and follow-up messages ordered after the compaction summary in the CLI and VS Code transcript.
@@ -0,0 +1,7 @@
---
"@kilocode/cli": patch
"@kilocode/kilo-gateway": patch
"kilo-code": patch
---
Restore Cloud Agent transcripts in VS Code session previews and stop cloud session previews or continuation from loading indefinitely when a request stalls.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Preserve unfinished inline review comments while diffs refresh.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Add Feedback & Support to the JetBrains empty session screen.
+12
View File
@@ -0,0 +1,12 @@
# kilocode_change - new file
name: Critical-only CodeQL config
query-filters:
- include:
kind:
- problem
- path-problem
- alert
- path-alert
tags contain: security
security-severity: /^(9(\.[0-9])?|10(\.0)?)$/
+117
View File
@@ -0,0 +1,117 @@
# kilocode_change - new file
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL Advanced"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '29 10 * * 5'
workflow_dispatch:
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: java-kotlin
build-mode: manual
- language: javascript-typescript
build-mode: none
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v6
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
- name: Setup Bun
if: matrix.language == 'java-kotlin'
uses: ./.github/actions/setup-bun
- name: Setup Java
if: matrix.language == 'java-kotlin'
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Setup Gradle
if: matrix.language == 'java-kotlin'
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
config-file: ./.github/codeql/codeql-config.yml
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- name: Build Java/Kotlin
if: matrix.language == 'java-kotlin'
shell: bash
run: ./gradlew typecheck
working-directory: packages/kilo-jetbrains
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"
+1
View File
@@ -32,6 +32,7 @@ jobs:
- name: windows
host: blacksmith-4vcpu-windows-2025 # kilocode_change
runs-on: ${{ matrix.settings.host }}
timeout-minutes: 45 # kilocode_change
defaults:
run:
shell: bash
+109
View File
@@ -0,0 +1,109 @@
# Plan: JetBrains Feedback & Support Button
## Goal
Add the VS Code empty-state `Feedback & Support` affordance to the JetBrains plugin, including a non-modal popup with the same three destinations:
- GitHub issues: `https://github.com/Kilo-Org/kilocode/issues/new/choose`
- Discord: `https://kilo.ai/discord`
- Customer support: `https://kilo.ai/support`
The popup must hide when clicking outside or pressing Escape.
## Findings
- The JetBrains empty state is `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt`.
- It already renders the logo, welcome copy, recents, and `Show History`; this is the right place to add the button.
- The VS Code implementation is:
- Button: `packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx`
- Popup content: `packages/kilo-vscode/webview-ui/src/components/chat/FeedbackDialog.tsx`
- Styling: `packages/kilo-vscode/webview-ui/src/styles/welcome.css`
- Local IntelliJ API source is available at `$INTELLIJ_REPO=/Users/kirillk/products/intellij-community`.
- IntelliJ popup API confirmed in local source:
- `JBPopupFactory.getInstance().createComponentPopupBuilder(content, focusComponent)`
- `setModalContext(false)` for non-modal context
- `setRequestFocus(false)` / `setFocusable(false)` if we want the popup not to steal focus
- `setCancelOnClickOutside(true)` for outside-click dismissal
- `setCancelKeyEnabled(true)` for Escape dismissal
- `setCancelOnWindowDeactivation(true)` and `setCancelOnOtherWindowOpen(true)` are available and appropriate for cleanup
- IntelliJ platform icons found in `AllIcons`:
- Feedback button: `AllIcons.Ide.Feedback`
- GitHub action: `AllIcons.Vcs.Vendors.Github`
- Support/help action: `AllIcons.Actions.Help` or `AllIcons.General.ContextHelp`
- Discord: no platform icon found. Add a Discord icon by borrowing the existing VS Code/kilo-ui Discord SVG artwork and adapting it into the JetBrains plugin resources with IntelliJ-compatible SVG colors and dark variant if needed.
## Implementation Steps
1. Add localized JetBrains bundle keys.
- Add base English strings to `frontend/src/main/resources/messages/KiloBundle.properties`:
- `feedback.button=Feedback & Support`
- `feedback.dialog.message=We'd love to hear your feedback or help with any issues you're experiencing.`
- `feedback.dialog.github=Report an issue on GitHub`
- `feedback.dialog.discord=Join our Discord community`
- `feedback.dialog.support=Customer Support`
- Reuse existing cancel text if present; otherwise add `common.cancel=Cancel` only if needed.
- Add matching keys to localized `KiloBundle_*.properties` files. If accurate translations are not already available from the VS Code i18n files, copy the VS Code translations for matching locales where practical and leave English fallback only if the JetBrains bundle mechanism supports it cleanly.
2. Extend `EmptySessionPanel` UI.
- Add a new retained `FeedbackButton`, styled to match VS Code conceptually:
- feedback icon + `Feedback & Support` text
- dashed link-colored border
- transparent background
- hand cursor
- hover state fills with link color and flips foreground/background for contrast
- Use IntelliJ theme APIs instead of raw colors:
- link color: `JBUI.CurrentTheme.Link.Foreground.ENABLED`
- hover link color: `JBUI.CurrentTheme.Link.Foreground.HOVERED` if useful
- editor/panel background from existing session style or `UiStyle.Colors.editorBackground()` / component background
- spacing via `UiStyle.Gap` / `JBUI.Borders`
- rounded arc via `UiStyle.Arc.component()` or `JBUI.getInt("Button.arc", 6)` at paint time
- Add the feedback button below the existing `Show History` button in the empty-state south area, keeping the current centered layout.
- Keep Swing mutations on EDT and preserve retained-component behavior.
3. Implement the feedback popup in frontend Swing code.
- Add a small popup builder method/class inside `EmptySessionPanel.kt` or a new nearby UI file if the file would become too large.
- Content should mirror VS Code:
- Kilo logo at top using existing `/icons/kilo-content.svg`
- message text
- three action buttons/rows for GitHub, Discord, and Customer Support
- every action should have an icon: `AllIcons.Vcs.Vendors.Github` for GitHub, the borrowed Discord SVG for Discord, and `AllIcons.Actions.Help` or `AllIcons.General.ContextHelp` for Customer Support
- optional Cancel button if it fits the JetBrains UX; Escape/outside click already dismisses
- Add the Discord icon asset under `frontend/src/main/resources/icons/` using the plugin's existing icon naming pattern, for example `discord.svg` and `discord_dark.svg` if the borrowed asset needs separate theme variants.
- Load the Discord icon with `IconLoader.getIcon("/icons/discord.svg", EmptySessionPanel::class.java)` or a small Kilo-owned icon object if multiple call sites need it.
- Use `BrowserUtil.browse(url)` to open links.
- Close the popup after opening a URL.
- Use `JBPopupFactory.createComponentPopupBuilder(content, null)` and configure:
- `.setModalContext(false)`
- `.setRequestFocus(false)`
- `.setFocusable(false)` unless keyboard tabbing inside popup is desired; if buttons need keyboard focus, use `.setFocusable(true)` with `.setRequestFocus(false)`
- `.setCancelOnClickOutside(true)`
- `.setCancelKeyEnabled(true)`
- `.setCancelOnWindowDeactivation(true)`
- `.setCancelOnOtherWindowOpen(true)`
- `.setResizable(false)`
- `.setMovable(false)`
- Show with `popup.showUnderneathOf(feedbackButton)`.
4. Add test coverage in `EmptySessionPanelTest.kt`.
- Assert feedback button text uses `KiloBundle.message("feedback.button")`.
- Assert feedback button has hand cursor and visible border semantics.
- Add an injectable browse callback or popup action callback only if needed for testing without launching browsers; keep it minimal and avoid production-only test hooks if component traversal can exercise enough behavior.
- Add a test that clicking the feedback button creates/shows popup content, if feasible in `BasePlatformTestCase`; otherwise test the retained popup content builder/action rows directly through package-internal methods.
- Assert the three action labels exist and URL callbacks map to the same URLs as VS Code.
- Assert the Discord action has an icon, so the plan cannot regress to a text-only Discord row.
5. Add release note.
- This is user-facing for the JetBrains plugin, so add a patch changeset under `.changeset/` unless the repo has a JetBrains-specific release-note mechanism that supersedes changesets.
- Suggested text: `Add Feedback & Support to the JetBrains empty session screen.`
6. Verify.
- Run the focused frontend/JetBrains tests first, preferably from `packages/kilo-jetbrains/`:
- `./gradlew :frontend:test --tests "ai.kilocode.client.session.ui.EmptySessionPanelTest"`
- Run JetBrains typecheck from `packages/kilo-jetbrains/`:
- `bun run typecheck` or `./gradlew typecheck`
- If touching only frontend UI and tests pass, no backend/SDK generation is needed.
## Notes
- Do not add Kotlin UI DSL, Compose, or JCEF.
- Do not modify shared opencode files; this work is entirely under `packages/kilo-jetbrains/` plus a changeset.
- Prefer platform icons where available, but explicitly borrow/add the Discord icon because `AllIcons` does not provide one and the popup should match the VS Code button set visually.
+3
View File
@@ -85,6 +85,7 @@
"dependencies": {
"@kilocode/kilo-web-ui": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@lottiefiles/dotlottie-web": "0.74.0",
"@opencode-ai/ui": "workspace:*",
"@solidjs/router": "catalog:",
"ghostty-web": "0.4.0",
@@ -1354,6 +1355,8 @@
"@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="],
"@lottiefiles/dotlottie-web": ["@lottiefiles/dotlottie-web@0.74.0", "", {}, "sha512-rG12+dJSVhQDdleGr9epR7zU/UJ6hh8jzBDHAurClHP5t5dSrOh3eP3UCFxolpVPcobaRVYsBnYg4HQokQewpg=="],
"@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
"@lydell/node-pty": ["@lydell/node-pty@1.2.0-beta.10", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.2.0-beta.10", "@lydell/node-pty-darwin-x64": "1.2.0-beta.10", "@lydell/node-pty-linux-arm64": "1.2.0-beta.10", "@lydell/node-pty-linux-x64": "1.2.0-beta.10", "@lydell/node-pty-win32-arm64": "1.2.0-beta.10", "@lydell/node-pty-win32-x64": "1.2.0-beta.10" } }, "sha512-Fv+A3+MZVA8qhkBIZsM1E6dCdHNMyXXz22mAYiMWd03LlyK///F3OH6CKPX9mj4id7LUlxpr45yPzyBVy9aDPw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-STfDdoDiRwXnQ3B8lueNSZp4iJFL9TAME6R+dnxI1eU=",
"aarch64-linux": "sha256-v/N0eCo1pa3n5QTMdLHBYICIEdI0BiNEsMzgRbOEXfA=",
"aarch64-darwin": "sha256-gC6iPlR+UTo336ukkHIzHKQMwxFOdyTIl9UCIh/XVdw=",
"x86_64-darwin": "sha256-es6bcIwldn0sKHnC7TqOaL+IST3zZ3JrHbH8ojGbQQ0="
"x86_64-linux": "sha256-b1eputqQyEDXT8LRZcGtx31TLm9wjUHHPJ8IivkPihY=",
"aarch64-linux": "sha256-0FGGe2pztqcWcMw1Ie+QlNTmJv0VM7vXTmCZ18M70hk=",
"aarch64-darwin": "sha256-McaXAipKk7yv2Ia1Py6Zp/WH2kDwRroy1/tnavbDYb4=",
"x86_64-darwin": "sha256-ARngNvK5UwQYZa5Uvx7o/cKWaQWY5/AlSXecX0UdtDM="
}
}
+1
View File
@@ -13,6 +13,7 @@
"dependencies": {
"@kilocode/kilo-web-ui": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@lottiefiles/dotlottie-web": "0.74.0",
"@opencode-ai/ui": "workspace:*",
"@solidjs/router": "catalog:",
"ghostty-web": "0.4.0",
Binary file not shown.
@@ -0,0 +1,34 @@
import { DotLottie } from "@lottiefiles/dotlottie-web"
import { onCleanup, onMount } from "solid-js"
const src = `${import.meta.env.BASE_URL}logo.lottie`
export function LoadingLogo(props: { class?: string }) {
let canvas: HTMLCanvasElement | undefined
onMount(() => {
if (!canvas) return
const motion = !window.matchMedia("(prefers-reduced-motion: reduce)").matches
const player = new DotLottie({
autoplay: motion,
canvas,
loop: motion,
src,
renderConfig: {
autoResize: true,
},
})
onCleanup(() => player.destroy())
})
return (
<canvas
ref={(node) => (canvas = node)}
class={`console-loading-logo${props.class ? ` ${props.class}` : ""}`}
role="img"
aria-label="Kilo loading animation"
/>
)
}
@@ -0,0 +1,20 @@
import { LoadingLogo } from "./LoadingLogo"
type Variant = "fullscreen" | "content"
export function LoadingScreen(props: { variant: Variant }) {
return (
<section
class="console-loading"
classList={{
"console-loading-fullscreen": props.variant === "fullscreen",
"console-loading-content": props.variant === "content",
}}
role="status"
aria-live="polite"
aria-label="Loading Kilo Console"
>
<LoadingLogo />
</section>
)
}
@@ -1,6 +1,7 @@
import { Show } from "solid-js"
import type { JSX } from "solid-js"
import { Card } from "@kilocode/kilo-web-ui/card"
import { LoadingScreen } from "../components/LoadingScreen"
import { ConfigProvider } from "../context/ConfigProvider"
import { useConfig } from "../context/config"
import { ConfigSidebar } from "../routes/config/ConfigSidebar"
@@ -43,9 +44,7 @@ function ConfigContent(props: { children?: JSX.Element }) {
</Card>
</Show>
<Show when={ctx.data.loading && !ctx.data()}>
<Card class="banner" variant="info">
Loading dashboard data...
</Card>
<LoadingScreen variant="content" />
</Show>
{props.children}
</section>
@@ -2,6 +2,7 @@ import { A, useLocation, useParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, Show } from "solid-js"
import { Card } from "@kilocode/kilo-web-ui/card"
import { Icon } from "@kilocode/kilo-web-ui/icon"
import { LoadingScreen } from "../../components/LoadingScreen"
import {
createProjectPty,
createProjectWorktree,
@@ -677,14 +678,10 @@ export function ProjectConsoleRoute() {
<main class="project-console-main">
<Show when={!query() && discoverable(search())}>
<Card class="banner" variant="info">
Discovering Kilo server...
</Card>
<LoadingScreen variant="fullscreen" />
</Show>
<Show when={snap.loading && !snap()}>
<Card class="banner" variant="info">
Loading project console...
</Card>
<LoadingScreen variant="fullscreen" />
</Show>
<Show when={snap.error || failure()}>
<Card class="banner" variant="error">
@@ -2,6 +2,7 @@ import { createEffect, createMemo, createResource, createSignal, For, Show } fro
import { A } from "@solidjs/router"
import { Card } from "@kilocode/kilo-web-ui/card"
import { SearchField } from "../../components/SearchField"
import { LoadingScreen } from "../../components/LoadingScreen"
import {
discover,
forgetCached,
@@ -131,15 +132,11 @@ export function ProjectsRoute() {
</header>
<Show when={!query() && discoverable()}>
<Card class="banner" variant="info">
Discovering Kilo server...
</Card>
<LoadingScreen variant="fullscreen" />
</Show>
<Show when={items.loading && !items()}>
<Card class="banner" variant="info">
Loading projects...
</Card>
<LoadingScreen variant="fullscreen" />
</Show>
<Show when={items.error}>
+1
View File
@@ -1,4 +1,5 @@
@import "./styles/base.css";
@import "./styles/loading.css";
@import "./styles/config.css";
@import "./styles/cli-ui.css";
@import "./styles/resolved.css";
@@ -0,0 +1,46 @@
.console-loading {
display: grid;
place-items: center;
background: var(--background);
}
.console-loading-fullscreen {
position: fixed;
z-index: 1000;
inset: 0;
width: 100vw;
height: 100vh;
}
.kilo-console .content {
position: relative;
}
.kilo-console .content > .console-loading-content {
position: absolute;
z-index: 10;
inset: 0;
}
.console-loading-logo {
display: block;
width: min(10rem, 28vw);
height: auto;
aspect-ratio: 1;
opacity: 0.5;
pointer-events: none;
}
.console-loading-fullscreen .console-loading-logo {
width: min(13rem, 31vw);
}
@media (max-width: 560px) {
.console-loading-logo {
width: min(8rem, 34vw);
}
.console-loading-fullscreen .console-loading-logo {
width: min(10rem, 36vw);
}
}
@@ -7,6 +7,7 @@ export interface DrizzleDb {
}
const INGEST_BASE = process.env.KILO_SESSION_INGEST_URL ?? "https://ingest.kilosessions.ai"
const TIMEOUT = 30_000
function exportUrl(sessionId: string) {
return UUID_RE.test(sessionId)
@@ -18,6 +19,7 @@ export type FetchResult = { ok: true; data: any } | { ok: false; status: number;
export async function fetchCloudSession(token: string, sessionId: string): Promise<FetchResult> {
const response = await fetch(exportUrl(sessionId), {
signal: AbortSignal.timeout(TIMEOUT),
headers: {
Authorization: `Bearer ${token}`,
...buildKiloHeaders(),
@@ -33,6 +35,7 @@ export async function fetchCloudSession(token: string, sessionId: string): Promi
export async function fetchCloudSessionForImport(token: string, sessionId: string): Promise<FetchResult> {
const response = await fetch(exportUrl(sessionId), {
signal: AbortSignal.timeout(TIMEOUT),
headers: {
Authorization: `Bearer ${token}`,
...buildKiloHeaders(),
@@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test"
import { fetchCloudSession, fetchCloudSessionForImport } from "../src/cloud-sessions"
async function expectStalledFetchToTimeOut(run: () => Promise<unknown>) {
const fetch = globalThis.fetch
const timeout = AbortSignal.timeout
let delay: number | undefined
AbortSignal.timeout = (ms) => {
delay = ms
const controller = new AbortController()
queueMicrotask(() => controller.abort(new DOMException("The operation timed out", "TimeoutError")))
return controller.signal
}
globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true })
})) as typeof globalThis.fetch
try {
const outcome = await Promise.race([
run().then(
() => "resolved" as const,
(err) => {
if (err instanceof DOMException && err.name === "TimeoutError") return "timed-out" as const
throw err
},
),
Bun.sleep(50).then(() => "still-pending" as const),
])
expect(outcome).toBe("timed-out")
expect(delay).toBe(30_000)
} finally {
globalThis.fetch = fetch
AbortSignal.timeout = timeout
}
}
describe("cloud session export requests", () => {
test("times out a stalled preview request", async () => {
await expectStalledFetchToTimeOut(() => fetchCloudSession("token", "session-id"))
})
test("times out a stalled import request", async () => {
await expectStalledFetchToTimeOut(() => fetchCloudSessionForImport("token", "session-id"))
})
})
@@ -27,6 +27,7 @@ import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
@@ -374,6 +375,7 @@ class KiloBackendAppService private constructor(
sessions.start(connection.api!!, connection.apiClient!!, connection.port, connection.events)
chat.start(connection.apiClient!!, connection.port, connection.events)
workspaces.start(connection.api!!, connection.apiClient!!, connection.port, connection.events)
startWatchingGlobalSseEvents()
setTelemetry(true)
captureBackend("Backend Connected", mapOf("portKnown" to "true"))
captureLoad("Backend Load Completed", start, mapOf(
@@ -390,7 +392,6 @@ class KiloBackendAppService private constructor(
)
)
log.info("Application started — config, profile, notifications loaded")
startWatchingGlobalSseEvents()
} catch (e: TimeoutCancellationException) {
val err = LoadError(
resource = "app",
@@ -675,7 +676,7 @@ class KiloBackendAppService private constructor(
synchronized(loadLock) {
if (eventWatcher?.isActive == true) return
log.info("Started watching global SSE events (config.updated, disposed)")
eventWatcher = cs.launch {
eventWatcher = cs.launch(start = CoroutineStart.UNDISPATCHED) {
connection.events.collect { event ->
when (event.type) {
"global.config.updated" -> {
@@ -12,7 +12,7 @@ import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.scroll.SessionScroll
import ai.kilocode.client.session.ui.ConnectionPanel
import ai.kilocode.client.session.ui.EmptySessionPanel
import ai.kilocode.client.session.ui.empty.EmptySessionPanel
import ai.kilocode.client.session.ui.LoadingPanel
import ai.kilocode.client.session.ui.ReasoningPicker
import ai.kilocode.client.session.ui.mode.ModePicker
@@ -0,0 +1,133 @@
package ai.kilocode.client.session.ui.empty
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import com.intellij.icons.AllIcons
import com.intellij.openapi.Disposable
import com.intellij.openapi.ui.popup.Balloon
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.IconLoader
import com.intellij.ui.awt.RelativePoint
import com.intellij.ui.components.JBLabel
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.xml.util.XmlStringUtil
import java.awt.Cursor
import java.awt.Point
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.JButton
import javax.swing.JComponent
internal class EmptySessionFeedback(
private val browse: (String) -> Unit,
) : Disposable {
private var balloon: Balloon? = null
val button: JButton = FeedbackButton().apply {
addActionListener { popup() }
}
@RequiresEdt
private fun popup() {
balloon?.let {
it.hide()
return
}
val content = content { url ->
browse(url)
balloon?.hide()
}
val point = RelativePoint(button, Point(button.width / 2, button.height + JBUI.scale(1)))
val popup = JBPopupFactory.getInstance()
.createBalloonBuilder(content)
.setHideOnClickOutside(true)
.setHideOnKeyOutside(true)
.setHideOnAction(true)
.setHideOnFrameResize(true)
.setBorderColor(UiStyle.Balloon.border())
.setFillColor(UiStyle.Balloon.bg())
.setBorderInsets(UiStyle.Balloon.insets())
.setPointerSize(UiStyle.Balloon.pointer())
.setCornerRadius(UiStyle.Balloon.arc())
.createBalloon()
balloon = popup
popup.setAnimationEnabled(false)
Disposer.register(popup) { balloon = null }
popup.show(point, Balloon.Position.below)
}
private class FeedbackButton : EmptySessionPanel.ShowHistoryButton(buttonHtml(), AllIcons.Ide.Feedback)
override fun dispose() {
balloon?.hide()
}
private class ActionButton(text: String, icon: javax.swing.Icon, action: () -> Unit) : JButton(text, icon) {
init {
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
addActionListener { action() }
}
}
companion object {
@RequiresEdt
fun content(open: (String) -> Unit): JComponent {
val logo = JBLabel(IconLoader.getIcon("/icons/kilo-content.svg", EmptySessionPanel::class.java)).apply {
horizontalAlignment = JBLabel.CENTER
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
open(KILO_URL)
}
})
}
val msg = JBLabel(messageHtml()).apply {
foreground = UIUtil.getLabelForeground()
horizontalAlignment = JBLabel.CENTER
}
val actions = Stack.vertical(gap = UiStyle.Gap.sm())
.next(ActionButton(KiloBundle.message("feedback.dialog.github"), AllIcons.Vcs.Vendors.Github) {
open(GITHUB_ISSUES_URL)
}.align(HAlign.CENTER, VAlign.CENTER))
.next(ActionButton(KiloBundle.message("feedback.dialog.discord"), DISCORD_ICON) {
open(DISCORD_URL)
}.align(HAlign.CENTER, VAlign.CENTER))
.next(ActionButton(KiloBundle.message("feedback.dialog.support"), AllIcons.Actions.Help) {
open(SUPPORT_URL)
}.align(HAlign.CENTER, VAlign.CENTER))
return Stack.vertical(gap = UiStyle.Gap.lg())
.fill(UiStyle.Gap.sm())
.next(logo.align(HAlign.CENTER, VAlign.CENTER))
.next(msg.align(HAlign.CENTER, VAlign.CENTER))
.fill(UiStyle.Gap.xs())
.next(actions.align(HAlign.CENTER, VAlign.CENTER))
.fill(UiStyle.Gap.xs())
}
fun urls() = listOf(GITHUB_ISSUES_URL, DISCORD_URL, SUPPORT_URL)
private fun messageHtml() = XmlStringUtil.wrapInHtml(
"<div style='text-align:center'>${XmlStringUtil.escapeString(KiloBundle.message("feedback.dialog.message"))}</div>"
)
private fun buttonHtml() = XmlStringUtil.wrapInHtml(
XmlStringUtil.escapeString(KiloBundle.message("feedback.button"))
)
private const val KILO_URL = "https://kilocode.ai"
private const val GITHUB_ISSUES_URL = "https://github.com/Kilo-Org/kilocode/issues/new/choose"
private const val DISCORD_URL = "https://kilo.ai/discord"
private const val SUPPORT_URL = "https://kilo.ai/support"
private val DISCORD_ICON = IconLoader.getIcon("/icons/discord.svg", EmptySessionPanel::class.java)
}
}
@@ -1,31 +1,25 @@
package ai.kilocode.client.session.ui
package ai.kilocode.client.session.ui.empty
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.history.HistoryActivitySnapshot
import ai.kilocode.client.session.history.HistoryTime
import ai.kilocode.client.session.history.LocalHistoryItem
import ai.kilocode.client.session.history.itemAt
import ai.kilocode.client.session.history.title
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Align
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import ai.kilocode.rpc.dto.SessionDto
import com.intellij.icons.AllIcons
import com.intellij.ide.BrowserUtil
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.IconLoader
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBList
import com.intellij.util.ui.Centerizer
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
@@ -35,19 +29,14 @@ import java.awt.BorderLayout
import java.awt.Component
import java.awt.Cursor
import java.awt.Dimension
import java.awt.FlowLayout
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.RenderingHints
import java.awt.event.HierarchyEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.awt.event.MouseMotionAdapter
import javax.swing.DefaultListModel
import javax.swing.JButton
import javax.swing.JList
import javax.swing.ListCellRenderer
import javax.swing.ListSelectionModel
import javax.swing.JComponent
import javax.swing.Timer
/**
@@ -63,51 +52,19 @@ class EmptySessionPanel(
private val history: () -> Unit = {},
private val activity: () -> Map<String, SessionActivityKind> = { emptyMap() },
private val titles: () -> Map<String, String> = { emptyMap() },
private val browse: (String) -> Unit = BrowserUtil::browse,
) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget {
val view: Align = align(HAlign.CENTER, VAlign.CENTER)
private val model = DefaultListModel<LocalHistoryItem>()
private var hover = -1
private var style = SessionEditorStyle.current()
private var snapshot = HistoryActivitySnapshot()
private val timer = Timer(ACTIVITY_MS) { syncActivity() }
private val recentTitle = JBLabel(KiloBundle.message("session.empty.recent")).apply {
foreground = UIUtil.getContextHelpForeground()
}
private val list = JBList(model).apply {
isOpaque = false
selectionMode = ListSelectionModel.SINGLE_SELECTION
visibleRowCount = SessionUiStyle.RecentSessions.LIMIT
cellRenderer = SessionRenderer()
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
emptyText.clear()
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
val item = itemAt(this@apply, e) ?: return
controller.openSession(SessionRef.Local(item.session))
}
override fun mouseExited(e: MouseEvent) {
hover = -1
repaint()
}
})
addMouseMotionListener(object : MouseMotionAdapter() {
override fun mouseMoved(e: MouseEvent) {
val index = index(e)
if (hover == index) return
hover = index
repaint()
}
})
}
internal val recent = RecentsList(recents, controller)
private val historyButton = ShowHistoryButton().apply {
addActionListener { history() }
}
private val feedback = EmptySessionFeedback(browse)
private val welcomeLabel = JBLabel(welcomeHtml()).apply {
foreground = UIUtil.getContextHelpForeground()
horizontalAlignment = JBLabel.CENTER
@@ -132,9 +89,9 @@ class EmptySessionPanel(
init {
Disposer.register(parent, this)
Disposer.register(this, feedback)
isOpaque = false
applyStyle(SessionEditorStyle.current())
setSessions(recents)
addHierarchyListener { e ->
if (e.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong() == 0L) return@addHierarchyListener
if (isShowing) {
@@ -159,40 +116,28 @@ class EmptySessionPanel(
add(description.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER)
}
val recent = BorderLayoutPanel().apply {
isOpaque = false
add(recentTitle, BorderLayout.NORTH)
add(list, BorderLayout.CENTER)
}
val south = BorderLayoutPanel().apply {
isOpaque = false
add(Centerizer(historyButton, Centerizer.TYPE.HORIZONTAL), BorderLayout.CENTER)
add(Stack.vertical(gap = UiStyle.Gap.lg())
.next(Centerizer(historyButton, Centerizer.TYPE.HORIZONTAL))
.next(Centerizer(feedback.button, Centerizer.TYPE.HORIZONTAL)), BorderLayout.CENTER)
}
add(header, BorderLayout.NORTH)
add(recent, BorderLayout.CENTER)
if (recent.hasSessions()) add(recent, BorderLayout.CENTER)
add(south, BorderLayout.SOUTH)
}
private fun setSessions(sessions: List<SessionDto>) {
model.clear()
sessions.take(SessionUiStyle.RecentSessions.LIMIT).map(::LocalHistoryItem).forEach(model::addElement)
revalidate()
repaint()
}
internal fun recentCount() = model.size()
internal fun recentCount() = recent.count()
internal fun selectRecent(index: Int) {
list.selectedIndex = index
recent.select(index)
}
internal fun selectedRecent() = list.selectedIndex
internal fun selectedRecent() = recent.selected()
internal fun clickRecent(index: Int) {
list.selectedIndex = index
controller.openSession(SessionRef.Local(model.getElementAt(index).session))
recent.click(index)
}
internal fun clickShowHistory() {
@@ -201,13 +146,23 @@ class EmptySessionPanel(
internal fun showHistoryText() = historyButton.text
internal fun feedbackText() = KiloBundle.message("feedback.button")
internal fun feedbackCursor() = feedback.button.cursor.type
internal fun feedbackIcon() = feedback.button.icon
internal fun feedbackBorderPainted() = feedback.button.isBorderPainted
internal fun feedbackContent(open: (String) -> Unit = {}): JComponent = EmptySessionFeedback.content(open)
internal fun feedbackUrls() = EmptySessionFeedback.urls()
internal fun showHistoryBorderPainted() = historyButton.isBorderPainted
internal fun showHistoryCursor() = historyButton.cursor.type
internal fun recentCursor() = list.cursor.type
internal fun recentVisible() = true
internal fun recentVisible() = recent.hasSessions()
internal fun explanationText() = KiloBundle.message("session.empty.welcome")
@@ -226,92 +181,25 @@ class EmptySessionPanel(
internal fun activeView() = getComponent(0)
internal fun text(session: SessionDto, now: Long = System.currentTimeMillis()) =
HistoryTime.relative(LocalHistoryItem(session), now)
recent.text(session, now)
internal fun rendererComponent(
session: SessionDto,
selected: Boolean = false,
hover: Boolean = false,
): Component {
val old = this.hover
this.hover = if (hover) 0 else -1
return list.cellRenderer.getListCellRendererComponent(list, LocalHistoryItem(session), 0, selected, false).also {
this.hover = old
}
return recent.renderer(session, selected, hover)
}
@RequiresEdt
internal fun syncActivity() {
val next = HistoryActivitySnapshot(activity(), titles())
val changed = snapshot.changed(next)
snapshot = next
repaintRows(changed)
recent.sync(activity(), titles())
}
private fun repaintRows(ids: Set<String>) {
if (ids.isEmpty()) return
repeat(model.size()) { index ->
if (model.getElementAt(index).id !in ids) return@repeat
list.getCellBounds(index, index)?.let(list::repaint)
}
}
private fun index(e: MouseEvent): Int {
val idx = list.locationToIndex(e.point)
if (idx < 0) return -1
val box = list.getCellBounds(idx, idx) ?: return -1
if (!box.contains(e.point)) return -1
return idx
}
private inner class SessionRenderer : BorderLayoutPanel(), ListCellRenderer<LocalHistoryItem> {
private val title = JBLabel()
private val badge = JBLabel().apply {
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
}
private val time = JBLabel()
private val head = BorderLayoutPanel().apply {
add(BorderLayoutPanel().apply {
layout = FlowLayout(FlowLayout.LEFT, 0, 0)
isOpaque = false
add(title)
add(badge)
}, BorderLayout.CENTER)
}
init {
layout = BorderLayout(UiStyle.Gap.pad(), 0)
border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg())
head.isOpaque = false
add(head, BorderLayout.CENTER)
add(time, BorderLayout.EAST)
}
override fun getListCellRendererComponent(
list: JList<out LocalHistoryItem>,
value: LocalHistoryItem?,
index: Int,
selected: Boolean,
focus: Boolean,
): Component {
val over = selected || hover == index
isOpaque = over
background = if (over) list.selectionBackground else list.background
title.foreground = if (over) list.selectionForeground else UIUtil.getLabelForeground()
time.foreground = if (over) list.selectionForeground else UIUtil.getContextHelpForeground()
title.text = value?.let { snapshot.titles[it.id] ?: title(it) } ?: ""
time.text = value?.let(HistoryTime::relative) ?: ""
setBadge(value?.id?.let(snapshot.activity::get))
return this
}
private fun setBadge(kind: SessionActivityKind?) {
badge.isVisible = kind != null
badge.icon = kind?.let { FilledBadgeIcon(it.label(), it.bg(), it.fg()) }
}
}
private inner class ShowHistoryButton : JButton(KiloBundle.message("session.showHistory"), AllIcons.Vcs.History) {
internal open class ShowHistoryButton(
text: String = KiloBundle.message("session.showHistory"),
icon: javax.swing.Icon = AllIcons.Vcs.History,
) : JButton(text, icon) {
private var over = false
init {
@@ -359,9 +247,8 @@ class EmptySessionPanel(
}
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
welcomeLabel.font = style.regularFont
recentTitle.font = style.smallFont
recent.applyStyle(style)
revalidate()
repaint()
}
@@ -374,7 +261,3 @@ class EmptySessionPanel(
const val ACTIVITY_MS = 3_000
}
}
private fun Map<String, String>.changed(next: Map<String, String>) = (keys + next.keys).filterTo(mutableSetOf()) {
this[it] != next[it]
}
@@ -0,0 +1,200 @@
package ai.kilocode.client.session.ui.empty
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.session.history.HistoryActivitySnapshot
import ai.kilocode.client.session.history.HistoryTime
import ai.kilocode.client.session.history.LocalHistoryItem
import ai.kilocode.client.session.history.itemAt
import ai.kilocode.client.session.history.title
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.rpc.dto.SessionDto
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBList
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Cursor
import java.awt.FlowLayout
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.awt.event.MouseMotionAdapter
import javax.swing.DefaultListModel
import javax.swing.JList
import javax.swing.ListCellRenderer
import javax.swing.ListSelectionModel
internal class RecentsList(
sessions: List<SessionDto>,
private val controller: SessionController,
) : BorderLayoutPanel(), SessionEditorStyleTarget {
private val model = DefaultListModel<LocalHistoryItem>()
private var hover = -1
private var snapshot = HistoryActivitySnapshot()
private val title = JBLabel(KiloBundle.message("session.empty.recent")).apply {
foreground = UIUtil.getContextHelpForeground()
}
internal val list = JBList(model).apply {
isOpaque = false
selectionMode = ListSelectionModel.SINGLE_SELECTION
visibleRowCount = SessionUiStyle.RecentSessions.LIMIT
cellRenderer = Renderer()
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
emptyText.clear()
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
val item = itemAt(this@apply, e) ?: return
controller.openSession(SessionRef.Local(item.session))
}
override fun mouseExited(e: MouseEvent) {
hover = -1
repaint()
}
})
addMouseMotionListener(object : MouseMotionAdapter() {
override fun mouseMoved(e: MouseEvent) {
val index = index(e)
if (hover == index) return
hover = index
repaint()
}
})
}
init {
isOpaque = false
add(title, BorderLayout.NORTH)
add(list, BorderLayout.CENTER)
setSessions(sessions)
}
fun count() = model.size()
fun hasSessions() = model.size() > 0
fun select(index: Int) {
list.selectedIndex = index
}
fun selected() = list.selectedIndex
fun click(index: Int) {
list.selectedIndex = index
controller.openSession(SessionRef.Local(model.getElementAt(index).session))
}
fun text(session: SessionDto, now: Long = System.currentTimeMillis()) =
HistoryTime.relative(LocalHistoryItem(session), now)
fun renderer(
session: SessionDto,
selected: Boolean = false,
hover: Boolean = false,
): Component {
val old = this.hover
this.hover = if (hover) 0 else -1
return list.cellRenderer.getListCellRendererComponent(list, LocalHistoryItem(session), 0, selected, false).also {
this.hover = old
}
}
@RequiresEdt
fun sync(activity: Map<String, SessionActivityKind>, titles: Map<String, String>) {
val next = HistoryActivitySnapshot(activity, titles)
val changed = snapshot.changed(next)
snapshot = next
repaintRows(changed)
}
override fun applyStyle(style: SessionEditorStyle) {
title.font = style.smallFont
revalidate()
repaint()
}
private fun setSessions(sessions: List<SessionDto>) {
model.clear()
sessions.take(SessionUiStyle.RecentSessions.LIMIT).map(::LocalHistoryItem).forEach(model::addElement)
revalidate()
repaint()
}
private fun repaintRows(ids: Set<String>) {
if (ids.isEmpty()) return
repeat(model.size()) { index ->
if (model.getElementAt(index).id !in ids) return@repeat
list.getCellBounds(index, index)?.let(list::repaint)
}
}
private fun index(e: MouseEvent): Int {
val idx = list.locationToIndex(e.point)
if (idx < 0) return -1
val box = list.getCellBounds(idx, idx) ?: return -1
if (!box.contains(e.point)) return -1
return idx
}
private inner class Renderer : BorderLayoutPanel(), ListCellRenderer<LocalHistoryItem> {
private val title = JBLabel()
private val badge = JBLabel().apply {
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
}
private val time = JBLabel()
private val head = BorderLayoutPanel().apply {
add(BorderLayoutPanel().apply {
layout = FlowLayout(FlowLayout.LEFT, 0, 0)
isOpaque = false
add(title)
add(badge)
}, BorderLayout.CENTER)
}
init {
layout = BorderLayout(UiStyle.Gap.pad(), 0)
border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg())
head.isOpaque = false
add(head, BorderLayout.CENTER)
add(time, BorderLayout.EAST)
}
override fun getListCellRendererComponent(
list: JList<out LocalHistoryItem>,
value: LocalHistoryItem?,
index: Int,
selected: Boolean,
focus: Boolean,
): Component {
val over = selected || hover == index
isOpaque = over
background = if (over) list.selectionBackground else list.background
title.foreground = if (over) list.selectionForeground else UIUtil.getLabelForeground()
time.foreground = if (over) list.selectionForeground else UIUtil.getContextHelpForeground()
title.text = value?.let { snapshot.titles[it.id] ?: title(it) } ?: ""
time.text = value?.let(HistoryTime::relative) ?: ""
setBadge(value?.id?.let(snapshot.activity::get))
return this
}
private fun setBadge(kind: SessionActivityKind?) {
badge.isVisible = kind != null
badge.icon = kind?.let { FilledBadgeIcon(it.label(), it.bg(), it.fg()) }
}
}
}
private fun Map<String, String>.changed(next: Map<String, String>) = (keys + next.keys).filterTo(mutableSetOf()) {
this[it] != next[it]
}
@@ -3,7 +3,6 @@ package ai.kilocode.client.session.ui.model
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.PickerButton
import ai.kilocode.rpc.dto.ModelSelectionDto
import com.intellij.icons.AllIcons
import com.intellij.openapi.ui.popup.JBPopup
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.ui.popup.util.PopupUtil
@@ -119,7 +118,7 @@ class ModelPicker : PickerButton() {
}
val item = selected ?: if (allowEmpty) null else items.firstOrNull()
text = if (item == null && allowEmpty) "$emptyText" else "${ModelText.sanitize(item?.display ?: items.first().display)}"
icon = if (item?.let(ModelText::collectsData) == true) AllIcons.General.Warning else null
icon = if (item?.let(ModelText::collectsData) == true) ModelPickerIcons.DATA_COLLECTED else null
horizontalTextPosition = SwingConstants.LEFT
iconTextGap = JBUI.CurrentTheme.ActionsList.elementIconGap()
toolTipText = if (item?.let(ModelText::collectsData) == true) ModelText.dataCollected() else KiloBundle.message("model.picker.tooltip")
@@ -0,0 +1,8 @@
package ai.kilocode.client.session.ui.model
import com.intellij.openapi.util.IconLoader
import javax.swing.Icon
internal object ModelPickerIcons {
val DATA_COLLECTED: Icon = IconLoader.getIcon("/icons/brain-circuit.svg", ModelPickerIcons::class.java)
}
@@ -69,7 +69,7 @@ internal class ModelPickerRenderer(
ModelText.freeBg(),
JBColor.namedColor("Kilo.ModelPicker.freeBadgeForeground", JBColor.WHITE),
)
private val warn = JBLabel(AllIcons.General.Warning).apply {
private val warn = JBLabel(ModelPickerIcons.DATA_COLLECTED).apply {
toolTipText = ModelText.dataCollected()
}
private val provider = JBLabel()
@@ -33,6 +33,20 @@ object UiStyle {
fun component() = com.intellij.util.ui.JBValue.UIInteger("Component.arc", 8).get()
}
/** Platform balloon styling used by lightweight contextual overlays. */
object Balloon {
fun bg(): Color = UIUtil.getPanelBackground()
fun border(): Color = JBUI.CurrentTheme.Popup.borderColor(true)
/** New UI parameter-info balloon insets: symmetric vertical padding with wider sides. */
fun insets() = JBUI.insets(6, 12, 6, 12)
fun pointer() = JBUI.size(16, 8)
fun arc() = JBUI.scale(8)
}
/** Theme-aware colors and color math used by multiple UI surfaces. */
object Colors {
fun bg(): Color = UIUtil.getPanelBackground()
@@ -0,0 +1,15 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 13a4.5 4.5 0 0 0 3-4" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.477 10.896a4 4 0 0 1 .585-.396" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 18a4 4 0 0 1-1.967-.516" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 13h4" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 18h6a2 2 0 0 1 2 2v1" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 8h8" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 8V5a2 2 0 0 1 2-2" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="16" cy="13" r=".5" fill="#6C707E"/>
<circle cx="18" cy="3" r=".5" fill="#6C707E"/>
<circle cx="20" cy="21" r=".5" fill="#6C707E"/>
<circle cx="20" cy="8" r=".5" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,15 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 13a4.5 4.5 0 0 0 3-4" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.477 10.896a4 4 0 0 1 .585-.396" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 18a4 4 0 0 1-1.967-.516" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 13h4" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 18h6a2 2 0 0 1 2 2v1" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 8h8" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 8V5a2 2 0 0 1 2-2" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="16" cy="13" r=".5" fill="#CED0D6"/>
<circle cx="18" cy="3" r=".5" fill="#CED0D6"/>
<circle cx="20" cy="21" r=".5" fill="#CED0D6"/>
<circle cx="20" cy="8" r=".5" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.0742 4.45014C14.9244 3.92097 13.7106 3.54556 12.4638 3.3335C12.2932 3.64011 12.1388 3.95557 12.0013 4.27856C10.6732 4.07738 9.32261 4.07738 7.99451 4.27856C7.85694 3.9556 7.70257 3.64014 7.53203 3.3335C6.28441 3.54735 5.06981 3.92365 3.91889 4.45291C1.63401 7.85128 1.01462 11.1652 1.32431 14.4322C2.6624 15.426 4.16009 16.1819 5.7523 16.6668C6.11082 16.1821 6.42806 15.6678 6.70066 15.1295C6.18289 14.9351 5.68315 14.6953 5.20723 14.4128C5.33249 14.3215 5.45499 14.2274 5.57336 14.136C6.95819 14.7907 8.46965 15.1302 9.99997 15.1302C11.5303 15.1302 13.0418 14.7907 14.4266 14.136C14.5463 14.2343 14.6688 14.3284 14.7927 14.4128C14.3159 14.6957 13.8152 14.9361 13.2965 15.1309C13.5688 15.669 13.8861 16.1828 14.2449 16.6668C15.8385 16.1838 17.3373 15.4283 18.6756 14.4335C19.039 10.645 18.0549 7.36145 16.0742 4.45014ZM7.09294 12.423C6.22992 12.423 5.51693 11.6357 5.51693 10.6671C5.51693 9.69852 6.20514 8.90427 7.09019 8.90427C7.97524 8.90427 8.68272 9.69852 8.66758 10.6671C8.65244 11.6357 7.97248 12.423 7.09294 12.423ZM12.907 12.423C12.0426 12.423 11.3324 11.6357 11.3324 10.6671C11.3324 9.69852 12.0206 8.90427 12.907 8.90427C13.7934 8.90427 14.4954 9.69852 14.4803 10.6671C14.4651 11.6357 13.7865 12.423 12.907 12.423Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.0742 4.45014C14.9244 3.92097 13.7106 3.54556 12.4638 3.3335C12.2932 3.64011 12.1388 3.95557 12.0013 4.27856C10.6732 4.07738 9.32261 4.07738 7.99451 4.27856C7.85694 3.9556 7.70257 3.64014 7.53203 3.3335C6.28441 3.54735 5.06981 3.92365 3.91889 4.45291C1.63401 7.85128 1.01462 11.1652 1.32431 14.4322C2.6624 15.426 4.16009 16.1819 5.7523 16.6668C6.11082 16.1821 6.42806 15.6678 6.70066 15.1295C6.18289 14.9351 5.68315 14.6953 5.20723 14.4128C5.33249 14.3215 5.45499 14.2274 5.57336 14.136C6.95819 14.7907 8.46965 15.1302 9.99997 15.1302C11.5303 15.1302 13.0418 14.7907 14.4266 14.136C14.5463 14.2343 14.6688 14.3284 14.7927 14.4128C14.3159 14.6957 13.8152 14.9361 13.2965 15.1309C13.5688 15.669 13.8861 16.1828 14.2449 16.6668C15.8385 16.1838 17.3373 15.4283 18.6756 14.4335C19.039 10.645 18.0549 7.36145 16.0742 4.45014ZM7.09294 12.423C6.22992 12.423 5.51693 11.6357 5.51693 10.6671C5.51693 9.69852 6.20514 8.90427 7.09019 8.90427C7.97524 8.90427 8.68272 9.69852 8.66758 10.6671C8.65244 11.6357 7.97248 12.423 7.09294 12.423ZM12.907 12.423C12.0426 12.423 11.3324 11.6357 11.3324 10.6671C11.3324 9.69852 12.0206 8.90427 12.907 8.90427C13.7934 8.90427 14.4954 9.69852 14.4803 10.6671C14.4651 11.6357 13.7865 12.423 12.907 12.423Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -13,6 +13,11 @@ session.account.switcher=Switch account
session.empty.loading=Loading...
session.empty.recent=RECENT
session.showHistory=Show History
feedback.button=Feedback and Support
feedback.dialog.message=We'd love to hear your feedback or help with any issues you're experiencing.
feedback.dialog.github=Report an issue on GitHub
feedback.dialog.discord=Join our Discord community
feedback.dialog.support=Customer Support
session.scroll.bottom=Scroll to bottom
session.scroll.question=Scroll to question
session.tab.new=New Session
@@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code هو مساعد برمجة بالذكاء الا
session.empty.loading=جاري التحميل…
session.empty.recent=الحديثة
session.showHistory=عرض السجل
feedback.button=التغذية الراجعة والدعم
feedback.dialog.message=يسعدنا سماع تعليقاتك أو مساعدتك في حل أي مشكلات تواجهها.
feedback.dialog.github=الإبلاغ عن مشكلة على GitHub
feedback.dialog.discord=الانضمام إلى مجتمع Discord
feedback.dialog.support=دعم العملاء
session.scroll.bottom=التمرير إلى الأسفل
session.tab.new=جلسة جديدة
session.tab.untitled=جلسة بدون عنوان
@@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code je AI asistent za kodiranje. Zatražite od njega
session.empty.loading=Učitavanje…
session.empty.recent=NEDAVNO
session.showHistory=Prikaži historiju
feedback.button=Povratne informacije i podrška
feedback.dialog.message=Voljeli bismo čuti vaše povratne informacije ili pomoći s problemima koje doživljavate.
feedback.dialog.github=Prijavite problem na GitHubu
feedback.dialog.discord=Pridružite se našoj Discord zajednici
feedback.dialog.support=Korisnička podrška
session.scroll.bottom=Skrolaj na dno
session.tab.new=Nova sesija
session.tab.untitled=Sesija bez naslova
@@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code er en AI-kodningsassistent. Bed den om at bygge
session.empty.loading=Indlæser…
session.empty.recent=SENESTE
session.showHistory=Vis historik
feedback.button=Feedback & support
feedback.dialog.message=Vi vil gerne høre din feedback eller hjælpe med eventuelle problemer, du oplever.
feedback.dialog.github=Rapportér et problem på GitHub
feedback.dialog.discord=Deltag i vores Discord-fællesskab
feedback.dialog.support=Kundesupport
session.scroll.bottom=Rul til bunden
session.tab.new=Ny session
session.tab.untitled=Unavngivet session
@@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code ist ein KI-Coding-Assistent. Bitten Sie ihn, Fun
session.empty.loading=Wird geladen…
session.empty.recent=ZULETZT
session.showHistory=Verlauf anzeigen
feedback.button=Feedback & Support
feedback.dialog.message=Wir würden uns freuen, Ihr Feedback zu hören oder Ihnen bei Problemen zu helfen.
feedback.dialog.github=Ein Problem auf GitHub melden
feedback.dialog.discord=Unserer Discord-Community beitreten
feedback.dialog.support=Kundensupport
session.scroll.bottom=Zum Ende scrollen
session.tab.new=Neue Sitzung
session.tab.untitled=Unbenannte Sitzung
@@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code es un asistente de codificación con IA. Pida qu
session.empty.loading=Cargando…
session.empty.recent=RECIENTE
session.showHistory=Mostrar historial
feedback.button=Comentarios y soporte
feedback.dialog.message=Nos encantaría escuchar tus comentarios o ayudarte con cualquier problema que estés experimentando.
feedback.dialog.github=Reportar un problema en GitHub
feedback.dialog.discord=Unirse a nuestra comunidad de Discord
feedback.dialog.support=Atención al cliente
session.scroll.bottom=Desplazarse al final
session.tab.new=Nueva sesión
session.tab.untitled=Sesión sin título
@@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code est un assistant de codage IA. Demandez-lui de c
session.empty.loading=Chargement…
session.empty.recent=RÉCENT
session.showHistory=Afficher l'historique
feedback.button=Commentaires & support
feedback.dialog.message=Nous aimerions recueillir vos commentaires ou vous aider avec les problèmes que vous rencontrez.
feedback.dialog.github=Signaler un problème sur GitHub
feedback.dialog.discord=Rejoindre notre communauté Discord
feedback.dialog.support=Service client
session.scroll.bottom=Faire défiler vers le bas
session.tab.new=Nouvelle session
session.tab.untitled=Session sans titre
@@ -9,6 +9,11 @@ session.empty.welcome=Kilo CodeはAIコーディングアシスタントです
session.empty.loading=読み込み中…
session.empty.recent=最近
session.showHistory=履歴を表示
feedback.button=フィードバック & サポート
feedback.dialog.message=フィードバックをお聞かせいただくか、問題がある場合はお気軽にご相談ください。
feedback.dialog.github=GitHubで問題を報告する
feedback.dialog.discord=Discordコミュニティに参加する
feedback.dialog.support=カスタマーサポート
session.scroll.bottom=一番下にスクロール
session.tab.new=新しいセッション
session.tab.untitled=名前なしのセッション
@@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code는 AI 코딩 어시스턴트입니다. 기능
session.empty.loading=로딩 중…
session.empty.recent=최근
session.showHistory=기록 보기
feedback.button=피드백 & 지원
feedback.dialog.message=피드백을 들려주시거나 겪고 계신 문제에 대해 도움을 드리고 싶습니다.
feedback.dialog.github=GitHub에 이슈 보고하기
feedback.dialog.discord=Discord 커뮤니티 참여하기
feedback.dialog.support=고객 지원
session.scroll.bottom=맨 아래로 스크롤
session.tab.new=새 세션
session.tab.untitled=제목 없는 세션
@@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code is een AI-codeerassistent. Vraag het om functies
session.empty.loading=Laden…
session.empty.recent=RECENT
session.showHistory=Geschiedenis weergeven
feedback.button=Feedback & Ondersteuning
feedback.dialog.message=We horen graag uw feedback of helpen met eventuele problemen die u ervaart.
feedback.dialog.github=Meld een probleem op GitHub
feedback.dialog.discord=Word lid van onze Discord community
feedback.dialog.support=Klantenservice
session.scroll.bottom=Naar beneden scrollen
session.tab.new=Nieuwe sessie
session.tab.untitled=Naamloze sessie
@@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code er en AI-kodingsassistent. Be den om å bygge fu
session.empty.loading=Laster…
session.empty.recent=NYLIGE
session.showHistory=Vis historikk
feedback.button=Tilbakemelding & støtte
feedback.dialog.message=Vi vil gjerne høre tilbakemeldingene dine eller hjelpe med problemer du opplever.
feedback.dialog.github=Rapporter et problem på GitHub
feedback.dialog.discord=Bli med i Discord-fellesskapet vårt
feedback.dialog.support=Kundestøtte
session.scroll.bottom=Rull til bunnen
session.tab.new=Ny økt
session.tab.untitled=Uten tittel
@@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code to asystent kodowania AI. Poproś go o tworzenie
session.empty.loading=Ładowanie…
session.empty.recent=OSTATNIE
session.showHistory=Pokaż historię
feedback.button=Opinie i wsparcie
feedback.dialog.message=Chętnie poznamy Twoją opinię lub pomożemy w przypadku problemów.
feedback.dialog.github=Zgłoś problem na GitHubie
feedback.dialog.discord=Dołącz do naszej społeczności Discord
feedback.dialog.support=Wsparcie klienta
session.scroll.bottom=Przewiń na dół
session.tab.new=Nowa sesja
session.tab.untitled=Sesja bez tytułu
@@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code é um assistente de codificação com IA. Peça
session.empty.loading=Carregando…
session.empty.recent=RECENTE
session.showHistory=Mostrar histórico
feedback.button=Feedback e suporte
feedback.dialog.message=Adoraríamos ouvir seu feedback ou ajudar com quaisquer problemas que você esteja enfrentando.
feedback.dialog.github=Reportar um problema no GitHub
feedback.dialog.discord=Entrar na nossa comunidade Discord
feedback.dialog.support=Suporte ao cliente
session.scroll.bottom=Rolar para o fim
session.tab.new=Nova sessão
session.tab.untitled=Sessão sem título
@@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code — это AI-ассистент по прогр
session.empty.loading=Загрузка…
session.empty.recent=НЕДАВНИЕ
session.showHistory=Показать историю
feedback.button=Отзывы и поддержка
feedback.dialog.message=Мы будем рады услышать ваши отзывы или помочь с любыми возникающими проблемами.
feedback.dialog.github=Сообщить о проблеме на GitHub
feedback.dialog.discord=Присоединиться к нашему Discord
feedback.dialog.support=Служба поддержки
session.scroll.bottom=Прокрутить вниз
session.tab.new=Новая сессия
session.tab.untitled=Незаголовок сессия
@@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code คือผู้ช่วยเขียนโ
session.empty.loading=กำลังโหลด…
session.empty.recent=ล่าสุด
session.showHistory=แสดงประวัติ
feedback.button=ข้อเสนอแนะและการสนับสนุน
feedback.dialog.message=เรายินดีรับฟังข้อเสนอแนะของคุณหรือช่วยแก้ไขปัญหาที่คุณพบ
feedback.dialog.github=รายงานปัญหาบน GitHub
feedback.dialog.discord=เข้าร่วมชุมชน Discord ของเรา
feedback.dialog.support=ฝ่ายสนับสนุนลูกค้า
session.scroll.bottom=เลื่อนไปด้านล่าง
session.tab.new=เซสชันใหม่
session.tab.untitled=เซสชันไม่มีชื่อ
@@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code, bir yapay zeka kodlama asistanıdır. Özellik
session.empty.loading=Yükleniyor…
session.empty.recent=SON
session.showHistory=Geçmişi göster
feedback.button=Geri Bildirim ve Destek
feedback.dialog.message=Geri bildiriminizi almaktan veya yaşadığınız sorunlarda yardımcı olmaktan mutluluk duyarız.
feedback.dialog.github=GitHub'da sorun bildirin
feedback.dialog.discord=Discord topluluğumuza katılın
feedback.dialog.support=Müşteri Desteği
session.scroll.bottom=En alta kaýr
session.tab.new=Yeni oturum
session.tab.untitled=Başlıksız oturum
@@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code — це AI-асистент для програ
session.empty.loading=Завантаження…
session.empty.recent=НЕДАВНІ
session.showHistory=Показати історію
feedback.button=Зворотний зв'язок і підтримка
feedback.dialog.message=Ми раді отримати ваш відгук або допомогти з будь-якими проблемами, які у вас виникли.
feedback.dialog.github=Повідомити про проблему на GitHub
feedback.dialog.discord=Приєднатися до нашої спільноти Discord
feedback.dialog.support=Служба підтримки клієнтів
session.scroll.bottom=Прокрутити донизу
session.tab.new=Нова сесія
session.tab.untitled=Сесія без назви
@@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code 是一个 AI 编程助手。可请它构建功
session.empty.loading=加载中…
session.empty.recent=最近
session.showHistory=显示历史
feedback.button=反馈与支持
feedback.dialog.message=我们很乐意听取您的反馈,或帮助解决您遇到的任何问题。
feedback.dialog.github=在 GitHub 上报告问题
feedback.dialog.discord=加入我们的 Discord 社区
feedback.dialog.support=客户支持
session.scroll.bottom=滚动到底部
session.tab.new=新建会话
session.tab.untitled=无标题会话
@@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code 是 AI 程式輔助。可請它建置功能、
session.empty.loading=載入中…
session.empty.recent=最近
session.showHistory=顯示歷史
feedback.button=意見回饋與支援
feedback.dialog.message=我們很樂意聆聽您的意見回饋,或協助解決您遇到的任何問題。
feedback.dialog.github=在 GitHub 上回報問題
feedback.dialog.discord=加入我們的 Discord 社群
feedback.dialog.support=客戶支援
session.scroll.bottom=滾動到底部
session.tab.new=新建工作階段
session.tab.untitled=未命名的工作階段
@@ -72,7 +72,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() {
val rpc = session("ses_1")
val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces)
val controller = controller(ui)
val panel = ai.kilocode.client.session.ui.EmptySessionPanel(testRootDisposable, controller, listOf(rpc))
val panel = ai.kilocode.client.session.ui.empty.EmptySessionPanel(testRootDisposable, controller, listOf(rpc))
panel.clickRecent(0)
@@ -84,7 +84,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() {
val manager = FakeManager()
val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces)
val controller = controller(ui)
val panel = ai.kilocode.client.session.ui.EmptySessionPanel(
val panel = ai.kilocode.client.session.ui.empty.EmptySessionPanel(
testRootDisposable,
controller,
emptyList(),
@@ -8,7 +8,7 @@ import ai.kilocode.client.session.model.QuestionItem
import ai.kilocode.client.session.model.QuestionOption
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.ui.ConnectionPanel
import ai.kilocode.client.session.ui.EmptySessionPanel
import ai.kilocode.client.session.ui.empty.EmptySessionPanel
import ai.kilocode.client.session.ui.LoadingPanel
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.session.ui.account.SessionAccountOverlay
@@ -6,11 +6,11 @@ import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.history.HistoryTime
import ai.kilocode.client.session.history.LocalHistoryItem
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.session.ui.empty.EmptySessionPanel
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeSessionRpcApi
@@ -33,6 +33,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import java.awt.BorderLayout
import java.awt.Cursor
import javax.swing.JButton
@Suppress("UnstableApiUsage")
class EmptySessionPanelTest : BasePlatformTestCase() {
@@ -82,10 +83,10 @@ class EmptySessionPanelTest : BasePlatformTestCase() {
assertFalse(panel.loadingVisible())
}
fun `test recent section remains visible when empty`() {
fun `test recent section is hidden when empty`() {
val panel = panel()
assertTrue(panel.recentVisible())
assertFalse(panel.recentVisible())
assertEquals(0, panel.recentCount())
}
@@ -160,12 +161,21 @@ class EmptySessionPanelTest : BasePlatformTestCase() {
assertEquals(ai.kilocode.client.plugin.KiloBundle.message("session.showHistory"), panel.showHistoryText())
}
fun `test feedback button uses localized text and icon`() {
val panel = panel()
assertEquals(KiloBundle.message("feedback.button"), panel.feedbackText())
assertNotNull(panel.feedbackIcon())
}
fun `test action controls use hand cursor and no show history outline`() {
val panel = panel()
assertFalse(panel.showHistoryBorderPainted())
assertFalse(panel.feedbackBorderPainted())
assertEquals(Cursor.HAND_CURSOR, panel.showHistoryCursor())
assertEquals(Cursor.HAND_CURSOR, panel.recentCursor())
assertEquals(Cursor.HAND_CURSOR, panel.feedbackCursor())
assertEquals(Cursor.HAND_CURSOR, panel.recent.list.cursor.type)
}
fun `test clicking show history delegates callback`() {
@@ -177,6 +187,36 @@ class EmptySessionPanelTest : BasePlatformTestCase() {
assertEquals(1, calls)
}
fun `test feedback popup content opens expected destinations`() {
val panel = panel()
val opened = mutableListOf<String>()
val content = panel.feedbackContent { opened.add(it) }
val buttons = UIUtil.uiTraverser(content).filter(JButton::class.java).toList()
assertEquals(
listOf(
KiloBundle.message("feedback.dialog.github"),
KiloBundle.message("feedback.dialog.discord"),
KiloBundle.message("feedback.dialog.support"),
),
buttons.map { it.text },
)
buttons.forEach { it.doClick() }
assertEquals(panel.feedbackUrls(), opened)
}
fun `test feedback discord action has icon`() {
val panel = panel()
val content = panel.feedbackContent()
val discord = UIUtil.uiTraverser(content)
.filter(JButton::class.java)
.first { it.text == KiloBundle.message("feedback.dialog.discord") }
assertNotNull(discord.icon)
}
fun `test renderer aligns title center and time east`() {
val cell = panel().rendererComponent(session("ses_1")) as BorderLayoutPanel
val layout = cell.layout as BorderLayout
@@ -239,7 +239,7 @@ class ModelPickerTest : BasePlatformTestCase() {
picker.setItems(listOf(item("auto", "Auto Free", "kilo", "Kilo", free = true)))
assertFalse(picker.text.contains("Data collected"))
assertSame(AllIcons.General.Warning, picker.icon)
assertSame(ModelPickerIcons.DATA_COLLECTED, picker.icon)
assertEquals("Data collected", picker.toolTipText)
}
+4
View File
@@ -2,6 +2,10 @@ import { Icon as Upstream, type IconProps as Props } from "@opencode-ai/ui/icon"
import { splitProps } from "solid-js"
const icons: Record<string, { path: string; viewBox: string }> = {
"brain-circuit": {
viewBox: "0 0 24 24",
path: `<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/><path d="M9 13a4.5 4.5 0 0 0 3-4" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/><path d="M6.003 5.125A3 3 0 0 0 6.401 6.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.477 10.896a4 4 0 0 1 .585-.396" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/><path d="M6 18a4 4 0 0 1-1.967-.516" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 13h4" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 18h6a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 8h8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/><path d="M16 8V5a2 2 0 0 1 2-2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/><circle cx="16" cy="13" r=".5" fill="currentColor"/><circle cx="18" cy="3" r=".5" fill="currentColor"/><circle cx="20" cy="21" r=".5" fill="currentColor"/><circle cx="20" cy="8" r=".5" fill="currentColor"/>`,
},
"circuit-board": {
viewBox: "0 0 16 16",
path: `<path d="M12.5 1H3.5C2.121 1 1 2.121 1 3.5V12.5C1 13.879 2.121 15 3.5 15H12.5C13.879 15 15 13.879 15 12.5V3.5C15 2.121 13.879 1 12.5 1ZM6 6.5C6 6.775 5.775 7 5.5 7C5.225 7 5 6.775 5 6.5C5 6.225 5.225 6 5.5 6C5.775 6 6 6.225 6 6.5ZM12.5 14H6V11.5C6 11.225 6.225 11 6.5 11H9.092C9.299 11.581 9.849 12 10.5 12C11.327 12 12 11.327 12 10.5C12 9.673 11.327 9 10.5 9C9.849 9 9.299 9.419 9.092 10H6.5C5.673 10 5 10.673 5 11.5V14H3.5C2.673 14 2 13.327 2 12.5V3.5C2 2.673 2.673 2 3.5 2H5V5.092C4.419 5.299 4 5.849 4 6.5C4 7.327 4.673 8 5.5 8C6.327 8 7 7.327 7 6.5C7 5.849 6.581 5.299 6 5.092V2H12.5C13.327 2 14 2.673 14 3.5V6H10.908C10.701 5.419 10.151 5 9.5 5C8.673 5 8 5.673 8 6.5C8 7.327 8.673 8 9.5 8C10.151 8 10.701 7.581 10.908 7H14V12.5C14 13.327 13.327 14 12.5 14ZM10 10.5C10 10.225 10.225 10 10.5 10C10.775 10 11 10.225 11 10.5C11 10.775 10.775 11 10.5 11C10.225 11 10 10.775 10 10.5ZM10 6.5C10 6.775 9.775 7 9.5 7C9.225 7 9 6.775 9 6.5C9 6.225 9.225 6 9.5 6C9.775 6 10 6.225 10 6.5Z" fill="currentColor"/>`,
+8 -1
View File
@@ -23,6 +23,7 @@ const kiloVscodeDir = join(import.meta.dir, "..")
const packagesDir = join(kiloVscodeDir, "..")
const opencodeDir = join(packagesDir, "opencode")
const coreDir = join(packagesDir, "core")
const gatewayDir = join(packagesDir, "kilo-gateway")
const indexingDir = join(packagesDir, "kilo-indexing")
const targetBinDir = join(kiloVscodeDir, "bin")
@@ -38,8 +39,12 @@ async function cliSourceHash(): Promise<string | null> {
try {
const opencodeResult = await $`git log -1 --format=%H -- .`.cwd(opencodeDir).quiet()
const coreResult = await $`git log -1 --format=%H -- .`.cwd(coreDir).quiet()
const gatewayResult = await $`git log -1 --format=%H -- .`.cwd(gatewayDir).quiet()
const indexingResult = await $`git log -1 --format=%H -- .`.cwd(indexingDir).quiet()
return `${opencodeResult.text().trim()}-${coreResult.text().trim()}-${indexingResult.text().trim()}` || null
return (
`${opencodeResult.text().trim()}-${coreResult.text().trim()}-${gatewayResult.text().trim()}-${indexingResult.text().trim()}` ||
null
)
} catch {
return null
}
@@ -49,10 +54,12 @@ async function isDirty(): Promise<boolean> {
try {
const opencodeResult = await $`git status --porcelain -- .`.cwd(opencodeDir).quiet()
const coreResult = await $`git status --porcelain -- .`.cwd(coreDir).quiet()
const gatewayResult = await $`git status --porcelain -- .`.cwd(gatewayDir).quiet()
const indexingResult = await $`git status --porcelain -- .`.cwd(indexingDir).quiet()
return (
opencodeResult.text().trim().length > 0 ||
coreResult.text().trim().length > 0 ||
gatewayResult.text().trim().length > 0 ||
indexingResult.text().trim().length > 0
)
} catch {
@@ -10,6 +10,8 @@ import type { CloudSessionData, EditorContext } from "../../services/cli-backend
import { getErrorMessage, sessionToWebview, mapCloudSessionMessageToWebviewMessage } from "../../kilo-provider-utils"
import type { MessageFile } from "../message-files"
const TIMEOUT = 30_000
export interface CloudSessionContext {
readonly client: KiloClient | null
currentSession: Session | null
@@ -73,7 +75,7 @@ export async function handleRequestCloudSessionData(ctx: CloudSessionContext, se
}
try {
const result = await ctx.client.kilo.cloud.session.get({ id: sessionId })
const result = await ctx.client.kilo.cloud.session.get({ id: sessionId }, { signal: AbortSignal.timeout(TIMEOUT) })
const data = result.data as CloudSessionData | undefined
if (!data) {
ctx.postMessage({
@@ -135,10 +137,13 @@ export async function handleImportAndSend(
// Step 1: Import the cloud session with fresh IDs
let session: Session | undefined
try {
const result = await ctx.client.kilo.cloud.session.import({
sessionId: cloudSessionId,
directory: dir,
})
const result = await ctx.client.kilo.cloud.session.import(
{
sessionId: cloudSessionId,
directory: dir,
},
{ signal: AbortSignal.timeout(TIMEOUT) },
)
session = result.data as Session | undefined
} catch (error) {
console.error("[Kilo New] KiloProvider: ❌ Cloud session import failed:", error)
@@ -0,0 +1,92 @@
import { describe, expect, it } from "bun:test"
import {
handleImportAndSend,
handleRequestCloudSessionData,
type CloudSessionContext,
} from "../../src/kilo-provider/handlers/cloud-session"
function stalled(options?: { signal?: AbortSignal }) {
return new Promise<never>((_resolve, reject) => {
options?.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true })
})
}
function context(sent: unknown[]) {
return {
client: {
kilo: {
cloud: {
session: {
get: (_params: { id: string }, options?: { signal?: AbortSignal }) => stalled(options),
import: (_params: { sessionId: string; directory: string }, options?: { signal?: AbortSignal }) =>
stalled(options),
},
},
},
},
currentSession: null,
trackedSessionIds: new Set<string>(),
connectionService: { recordMessageSessionId: () => undefined },
postMessage: (message: unknown) => sent.push(message),
getWorkspaceDirectory: () => "/repo",
gatherEditorContext: async () => ({}),
} as unknown as CloudSessionContext
}
describe("cloud session preview handler", () => {
it("reports a failure when the CLI preview request stalls", async () => {
const timeout = AbortSignal.timeout
AbortSignal.timeout = () => {
const controller = new AbortController()
queueMicrotask(() => controller.abort(new DOMException("The operation timed out", "TimeoutError")))
return controller.signal
}
try {
const sent: unknown[] = []
const outcome = await Promise.race([
handleRequestCloudSessionData(context(sent), "cloud-session").then(() => "resolved" as const),
Bun.sleep(50).then(() => "still-pending" as const),
])
expect(outcome).toBe("resolved")
expect(sent).toEqual([
{
type: "cloudSessionImportFailed",
cloudSessionId: "cloud-session",
error: "The operation timed out",
},
])
} finally {
AbortSignal.timeout = timeout
}
})
it("reports a failure when the CLI import request stalls", async () => {
const timeout = AbortSignal.timeout
AbortSignal.timeout = () => {
const controller = new AbortController()
queueMicrotask(() => controller.abort(new DOMException("The operation timed out", "TimeoutError")))
return controller.signal
}
try {
const sent: unknown[] = []
const outcome = await Promise.race([
handleImportAndSend(context(sent), "cloud-session", "Continue").then(() => "resolved" as const),
Bun.sleep(50).then(() => "still-pending" as const),
])
expect(outcome).toBe("resolved")
expect(sent).toEqual([
{
type: "cloudSessionImportFailed",
cloudSessionId: "cloud-session",
error: "The operation timed out",
},
])
} finally {
AbortSignal.timeout = timeout
}
})
})
@@ -4,6 +4,9 @@ import path from "node:path"
const root = path.resolve(import.meta.dir, "../..")
const preview = fs.readFileSync(path.join(root, "webview-ui/src/components/shared/ModelPreview.tsx"), "utf8")
const selector = fs.readFileSync(path.join(root, "webview-ui/src/components/shared/ModelSelector.tsx"), "utf8")
const agent = fs.readFileSync(path.join(root, "webview-ui/agent-manager/MultiModelSelector.tsx"), "utf8")
const icons = fs.readFileSync(path.join(root, "../kilo-ui/src/components/icon.tsx"), "utf8")
const styles = fs.readFileSync(path.join(root, "webview-ui/src/styles/model-selector.css"), "utf8")
describe("model preview data collection line", () => {
@@ -13,9 +16,17 @@ describe("model preview data collection line", () => {
expect(data).toBeGreaterThanOrEqual(0)
expect(context).toBeGreaterThan(data)
expect(preview).toContain('Icon name="warning"')
expect(preview).toContain('Icon name="brain-circuit"')
expect(preview).toContain("isDataCollectedModel(model())")
expect(preview).toContain('language.t("model.tag.dataCollected")')
expect(styles).toContain(".model-preview-data-line")
})
it("uses the brain circuit icon for all webview model data disclosures", () => {
expect(selector).toContain('Icon name="brain-circuit"')
expect(selector).not.toContain('Icon name="warning"')
expect(agent).toContain('Icon name="brain-circuit"')
expect(agent).not.toContain('Icon name="warning"')
expect(icons).toContain('"brain-circuit"')
})
})
@@ -9,7 +9,12 @@ import {
} from "../../webview-ui/agent-manager/review-comments"
import { markdownCommentBlocks } from "../../webview-ui/agent-manager/markdown-comment-ranges"
import {
buildFileAnnotations,
clearReviewComposer,
createReviewComposer,
reviewAnnotationSpeechKey,
reviewComposerDraft,
reviewComposerEdit,
reviewDraftSpeechKey,
reviewEditSpeechKey,
} from "../../webview-ui/agent-manager/review-annotations"
@@ -288,6 +293,93 @@ describe("review annotation speech keys", () => {
})
})
// ── buildFileAnnotations composer metadata ─────────────────────────────────
describe("buildFileAnnotations composer metadata", () => {
it("preserves unfinished draft text when an annotation is rebuilt", () => {
const draft = { file: "a.ts", side: "additions" as const, line: 2 }
const first = buildFileAnnotations("a.ts", [], null, draft, null, null)
if (!first.draftMeta) throw new Error("expected draft metadata")
first.draftMeta.text = "unfinished draft"
const next = buildFileAnnotations("a.ts", [], null, draft, first.draftMeta, first.editMeta)
expect(next.draftMeta).toBe(first.draftMeta)
expect(next.draftMeta?.text).toBe("unfinished draft")
})
it("creates fresh draft metadata when the anchor changes", () => {
const draft = { file: "a.ts", side: "additions" as const, line: 2 }
const first = buildFileAnnotations("a.ts", [], null, draft, null, null)
const next = buildFileAnnotations("a.ts", [], null, { ...draft, line: 3 }, first.draftMeta, first.editMeta)
expect(next.draftMeta).not.toBe(first.draftMeta)
})
it("preserves unfinished edits when an annotation is rebuilt", () => {
const current = comment({ file: "a.ts", line: 2 })
const first = buildFileAnnotations("a.ts", [current], current.id, null, null, null)
if (!first.editMeta) throw new Error("expected edit metadata")
first.editMeta.text = "unfinished edit"
const next = buildFileAnnotations("a.ts", [current], current.id, null, first.draftMeta, first.editMeta)
expect(next.editMeta).toBe(first.editMeta)
expect(next.editMeta?.text).toBe("unfinished edit")
})
it("creates fresh edit metadata when the edited comment changes", () => {
const firstComment = comment({ file: "a.ts", line: 2 })
const secondComment = comment({ file: "a.ts", line: 3 })
const first = buildFileAnnotations("a.ts", [firstComment], firstComment.id, null, null, null)
const next = buildFileAnnotations("a.ts", [secondComment], secondComment.id, null, first.draftMeta, first.editMeta)
expect(next.editMeta).not.toBe(first.editMeta)
})
it("drops unfinished edit metadata after edit mode ends", () => {
const current = comment({ file: "a.ts", line: 2 })
const first = buildFileAnnotations("a.ts", [current], current.id, null, null, null)
const next = buildFileAnnotations("a.ts", [current], null, null, first.draftMeta, first.editMeta)
expect(next.editMeta).toBeNull()
})
it("hands draft and edit composers between review surfaces", () => {
const current = comment({ file: "a.ts", line: 2 })
const composer = createReviewComposer()
const draft = { file: "a.ts", side: "additions" as const, line: 3 }
const first = buildFileAnnotations("a.ts", [current], current.id, draft, null, null)
if (!first.draftMeta || !first.editMeta) throw new Error("expected composer metadata")
first.draftMeta.text = "unfinished draft"
first.editMeta.text = "unfinished edit"
composer.draft = first.draftMeta
composer.edit = first.editMeta
expect(reviewComposerDraft(composer)).toEqual(draft)
expect(reviewComposerEdit(composer)).toBe(current.id)
expect(composer.draft.text).toBe("unfinished draft")
expect(composer.edit.text).toBe("unfinished edit")
})
it("clears handed-off composers when the review context changes", () => {
const composer = createReviewComposer()
composer.draft = { type: "draft", comment: null, file: "a.ts", side: "additions", line: 2 }
composer.edit = {
type: "comment",
comment: comment({ file: "a.ts", line: 2 }),
file: "a.ts",
side: "additions",
line: 2,
}
clearReviewComposer(composer)
expect(reviewComposerDraft(composer)).toBeNull()
expect(reviewComposerEdit(composer)).toBeNull()
})
})
// ── getDirectory / getFilename ──────────────────────────────────────────────
describe("getDirectory", () => {
@@ -17,6 +17,11 @@ const base = {
const user = (id: string): Message => ({ ...base, id, role: "user" })
const compact = (id: string): Message => ({
...user(id),
parts: [{ id: `part_${id}`, sessionID: base.sessionID, messageID: id, type: "compaction", auto: false }],
})
const assistant = (id: string, parentID: string, opts: Partial<Message> = {}): Message => ({
...base,
id,
@@ -316,6 +321,42 @@ describe("messageTurns", () => {
])
})
it("keeps resumed replies after a persisted compaction turn", () => {
const messages = [
user("message_1"),
assistant("message_2", "message_1"),
compact("message_3"),
assistant("message_4", "message_3", { summary: true, finish: "stop" }),
assistant("message_5", "message_1", { finish: "stop" }),
]
expect(
messageTurns(messages).map((turn) => ({
user: turn.user.id,
assistant: turn.assistant.map((msg) => msg.id),
})),
).toEqual([
{ user: "message_1", assistant: ["message_2"] },
{ user: "message_3", assistant: ["message_4", "message_5"] },
])
})
it("detects persisted compaction parts through the lazy lookup", () => {
const messages = [
user("message_1"),
assistant("message_2", "message_1"),
user("message_3"),
assistant("message_4", "message_3", { summary: true, finish: "stop" }),
assistant("message_5", "message_1", { finish: "stop" }),
]
expect(
visibleMessages(messages, undefined, (msg) => (msg.id === "message_3" ? compact(msg.id).parts : msg.parts)).map(
(msg) => msg.id,
),
).toEqual(["message_1", "message_2", "message_3", "message_4", "message_5"])
})
it("surfaces leading assistant output as partial turns grouped by parent", () => {
const messages = [
assistant("message_2", "message_1"),
@@ -437,6 +478,49 @@ describe("activeUserMessageID", () => {
expect(activeUserMessageID(messages, { type: "busy" })).toBe("message_1")
})
it("maps resumed post-compaction tool calls to the compaction turn", () => {
const messages = [
user("message_1"),
assistant("message_2", "message_1"),
compact("message_3"),
assistant("message_4", "message_3", { summary: true, finish: "stop" }),
assistant("message_5", "message_1", { finish: "tool-calls" }),
user("message_6"),
]
expect(activeUserMessageID(messages, { type: "busy" })).toBe("message_3")
expectLayout(messages, { type: "busy" }, { virtual: ["message_1"], direct: ["message_3"], queued: ["message_6"] })
})
it("uses lazy compaction parts when mapping resumed tool calls", () => {
const messages = [
user("message_1"),
assistant("message_2", "message_1"),
user("message_3"),
assistant("message_4", "message_3", { summary: true, finish: "stop" }),
assistant("message_5", "message_1", { finish: "tool-calls" }),
]
expect(
activeUserMessageID(messages, { type: "busy" }, (msg) =>
msg.id === "message_3" ? compact(msg.id).parts : msg.parts,
),
).toBe("message_3")
})
it("advances beyond a completed post-compaction reply", () => {
const messages = [
user("message_1"),
assistant("message_2", "message_1", { finish: "stop" }),
compact("message_3"),
assistant("message_4", "message_3", { summary: true, finish: "stop" }),
assistant("message_5", "message_1", { finish: "stop" }),
user("message_6"),
]
expect(activeUserMessageID(messages, { type: "busy" })).toBe("message_6")
})
it("ignores completed tool-call assistants after the session becomes idle", () => {
const messages = [
user("message_1"),
@@ -107,6 +107,7 @@ import { FullScreenDiffView } from "./FullScreenDiffView"
import { ApplyDialog } from "./ApplyDialog"
import { groupApplyConflicts } from "./apply-conflicts"
import type { ReviewComment } from "./review-comments"
import { clearReviewComposer, createReviewComposer } from "./review-annotations"
import { CurrentTabsMenu, createCurrentTabItems, focusCurrentTab } from "./CurrentTabsMenu"
import { BranchSelect } from "../src/components/shared/BranchSelect"
import { WorktreeItem } from "./WorktreeItem"
@@ -246,6 +247,7 @@ const AgentManagerContent: Component = () => {
const [reviewOpenByContext, setReviewOpenByContext] = createSignal<Record<string, boolean>>({})
const [reviewCommentsByContext, setReviewCommentsByContext] = createSignal<Record<string, ReviewComment[]>>({})
const reviewComposer = createReviewComposer()
const [reviewActive, setReviewActive] = createSignal(false)
const [reviewDiffStyle, setReviewDiffStyle] = createSignal<"unified" | "split">("unified")
const markdown = createMarkdownRender(vscode)
@@ -285,6 +287,7 @@ const AgentManagerContent: Component = () => {
setPendingDelete(null)
}
createEffect(on(selection, () => cancelPendingDelete(), { defer: true }))
createEffect(on(selection, () => clearReviewComposer(reviewComposer), { defer: true }))
onCleanup(() => clearTimeout(pendingDeleteTimer))
// Per-context tab memory: maps sidebar selection key -> last active session/pending ID
@@ -3063,6 +3066,7 @@ const AgentManagerContent: Component = () => {
onMarkdownRenderChange={markdown.update}
comments={reviewComments()}
onCommentsChange={setReviewCommentsForSelection}
composer={reviewComposer}
onClose={() => setSidePanel(null)}
onExpand={selection() !== null ? openReviewTab : undefined}
onRequestDiff={requestDiffFile}
@@ -3092,6 +3096,7 @@ const AgentManagerContent: Component = () => {
sessionKey={diffSessionKey()}
comments={reviewComments()}
onCommentsChange={setReviewCommentsForSelection}
composer={reviewComposer}
onSendAll={closeReviewTab}
diffStyle={reviewDiffStyle()}
onDiffStyleChange={setSharedDiffStyle}
@@ -24,10 +24,16 @@ import { getDirectory, getFilename, lineCount, sanitizeReviewComments, type Revi
import {
buildFileAnnotations,
buildReviewAnnotation,
clearReviewComposer,
createReviewComposer,
reviewComposerDraft,
reviewComposerEdit,
reviewDraftSpeechKey,
reviewEditSpeechKey,
type AnnotationLabels,
type AnnotationMeta,
type ReviewComposer,
type ReviewDraft,
} from "./review-annotations"
import { createReviewAnnotationSpeechRenderer } from "./review-annotation-speech"
import {
@@ -57,6 +63,7 @@ interface DiffPanelProps {
onMarkdownRenderChange?: (render: boolean) => void
comments: ReviewComment[]
onCommentsChange: (comments: ReviewComment[]) => void
composer?: ReviewComposer
onSendAll?: () => void
onClose: () => void
onExpand?: () => void
@@ -90,11 +97,11 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
edit: t("common.edit"),
delete: t("common.delete"),
})
const localComposer = createReviewComposer()
const composer = () => props.composer ?? localComposer
const [open, setOpen] = createSignal<string[]>([])
const [draft, setDraft] = createSignal<{ file: string; side: AnnotationSide; line: number; endLine?: number } | null>(
null,
)
const [editing, setEditing] = createSignal<string | null>(null)
const [draft, setDraft] = createSignal<ReviewDraft | null>(reviewComposerDraft(composer()))
const [editing, setEditing] = createSignal<string | null>(reviewComposerEdit(composer()))
const speechKeys = createMemo(() => {
const keys = new Set<string>()
const current = draft()
@@ -127,9 +134,10 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
const setComments = (next: ReviewComment[]) => props.onCommentsChange(next)
const updateComments = (updater: (prev: ReviewComment[]) => ReviewComment[]) => setComments(updater(comments()))
// Stable draft metadata ref avoids recreating the object on every signal read
// so pierre's annotation cache doesn't invalidate and destroy the textarea
let draftMeta: AnnotationMeta | null = null
// Stable composer metadata refs avoid recreating the object on every signal read
// so pierre's annotation cache doesn't invalidate and destroy the textarea.
let draftMeta: AnnotationMeta | null = composer().draft
let editMeta: AnnotationMeta | null = composer().edit
// Ref to the scrollable container — used to preserve scroll position when
// annotation changes cause pierre to fully re-render diffs
@@ -171,6 +179,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
preserveScroll(() => {
setDraft(null)
draftMeta = null
composer().draft = null
})
focusRoot()
}
@@ -214,7 +223,13 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
() => props.sessionKey,
() => {
requested.clear()
setDraft(null)
draftMeta = null
setEditing(null)
editMeta = null
clearReviewComposer(composer())
},
{ defer: true },
),
)
@@ -250,6 +265,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
updateComments((prev) => [...prev, { id, file, side, line, comment: text, selectedText }])
setDraft(null)
draftMeta = null
composer().draft = null
})
focusRoot()
}
@@ -258,6 +274,8 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
preserveScroll(() => {
updateComments((prev) => prev.map((c) => (c.id === id ? { ...c, comment: text } : c)))
setEditing(null)
editMeta = null
composer().edit = null
})
focusRoot()
}
@@ -265,12 +283,20 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
const deleteComment = (id: string) => {
preserveScroll(() => {
updateComments((prev) => prev.filter((c) => c.id !== id))
if (editing() === id) setEditing(null)
if (editing() === id) {
setEditing(null)
editMeta = null
composer().edit = null
}
})
focusRoot()
}
const setEditState = (id: string | null) => {
if (editing() !== id) {
editMeta = null
composer().edit = null
}
preserveScroll(() => setEditing(id))
if (id === null) focusRoot()
}
@@ -287,6 +313,8 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
const edit = editing()
if (edit && !valid.some((comment) => comment.id === edit)) {
setEditing(null)
editMeta = null
composer().edit = null
}
const currentDraft = draft()
@@ -295,6 +323,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
if (!diff) {
setDraft(null)
draftMeta = null
composer().draft = null
return
}
const content = currentDraft.side === "deletions" ? diff.before : diff.after
@@ -302,11 +331,13 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
if (currentDraft.line < 1 || currentDraft.line > max) {
setDraft(null)
draftMeta = null
composer().draft = null
return
}
if (currentDraft.endLine !== undefined && currentDraft.endLine > max) {
setDraft(null)
draftMeta = null
composer().draft = null
}
},
),
@@ -325,8 +356,11 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
})
const annotationsForFile = (file: string): DiffLineAnnotation<AnnotationMeta>[] => {
const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta)
const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta, editMeta)
draftMeta = result.draftMeta
editMeta = result.editMeta
composer().draft = draft() ? draftMeta : null
composer().edit = editing() ? editMeta : null
return result.annotations
}
@@ -356,7 +390,10 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
if (draft()) return
const side: AnnotationSide = range.side === "deletions" ? "deletions" : "additions"
preserveScroll(() => {
setDraft({ file, side, line: range.start, endLine: range.end })
const next = { file, side, line: range.start, endLine: range.end }
draftMeta = { type: "draft", comment: null, ...next }
composer().draft = draftMeta
setDraft(next)
})
}
@@ -32,10 +32,16 @@ import { getDirectory, getFilename, lineCount, sanitizeReviewComments, type Revi
import {
buildFileAnnotations,
buildReviewAnnotation,
clearReviewComposer,
createReviewComposer,
reviewComposerDraft,
reviewComposerEdit,
reviewDraftSpeechKey,
reviewEditSpeechKey,
type AnnotationLabels,
type AnnotationMeta,
type ReviewComposer,
type ReviewDraft,
} from "./review-annotations"
import { createReviewAnnotationSpeechRenderer } from "./review-annotation-speech"
import {
@@ -60,6 +66,7 @@ interface FullScreenDiffViewProps {
sessionKey?: string
comments: ReviewComment[]
onCommentsChange: (comments: ReviewComment[]) => void
composer?: ReviewComposer
onSendAll?: () => void
diffStyle: DiffStyle
onDiffStyleChange: (style: DiffStyle) => void
@@ -100,11 +107,11 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
edit: t("common.edit"),
delete: t("common.delete"),
})
const localComposer = createReviewComposer()
const composer = () => props.composer ?? localComposer
const [open, setOpen] = createSignal<string[]>([])
const [draft, setDraft] = createSignal<{ file: string; side: AnnotationSide; line: number; endLine?: number } | null>(
null,
)
const [editing, setEditing] = createSignal<string | null>(null)
const [draft, setDraft] = createSignal<ReviewDraft | null>(reviewComposerDraft(composer()))
const [editing, setEditing] = createSignal<string | null>(reviewComposerEdit(composer()))
const speechKeys = createMemo(() => {
const keys = new Set<string>()
const current = draft()
@@ -123,7 +130,8 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
const [activeFile, setActiveFile] = createSignal<string | null>(null)
const [treeWidth, setTreeWidth] = createSignal(240)
let nextId = 0
let draftMeta: AnnotationMeta | null = null
let draftMeta: AnnotationMeta | null = composer().draft
let editMeta: AnnotationMeta | null = composer().edit
// Tracks the session key for which initial open state has already run. When the
// key changes (different worktree) we expand reviewable files. Within the same key,
// only pruning happens so the user's manual collapse state is preserved.
@@ -174,6 +182,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
preserveScroll(() => {
setDraft(null)
draftMeta = null
composer().draft = null
})
focusRoot()
}
@@ -224,7 +233,13 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
() => props.sessionKey,
() => {
requested.clear()
setDraft(null)
draftMeta = null
setEditing(null)
editMeta = null
clearReviewComposer(composer())
},
{ defer: true },
),
)
@@ -260,6 +275,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
updateComments((prev) => [...prev, { id, file, side, line, comment: text, selectedText }])
setDraft(null)
draftMeta = null
composer().draft = null
})
focusRoot()
}
@@ -268,6 +284,8 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
preserveScroll(() => {
updateComments((prev) => prev.map((c) => (c.id === id ? { ...c, comment: text } : c)))
setEditing(null)
editMeta = null
composer().edit = null
})
focusRoot()
}
@@ -275,12 +293,20 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
const deleteComment = (id: string) => {
preserveScroll(() => {
updateComments((prev) => prev.filter((c) => c.id !== id))
if (editing() === id) setEditing(null)
if (editing() === id) {
setEditing(null)
editMeta = null
composer().edit = null
}
})
focusRoot()
}
const setEditState = (id: string | null) => {
if (editing() !== id) {
editMeta = null
composer().edit = null
}
preserveScroll(() => setEditing(id))
if (id === null) focusRoot()
}
@@ -302,6 +328,8 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
const edit = editing()
if (edit && !valid.some((comment) => comment.id === edit)) {
setEditing(null)
editMeta = null
composer().edit = null
}
const currentDraft = draft()
@@ -310,6 +338,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
if (!diff) {
setDraft(null)
draftMeta = null
composer().draft = null
return
}
const content = currentDraft.side === "deletions" ? diff.before : diff.after
@@ -317,11 +346,13 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
if (currentDraft.line < 1 || currentDraft.line > max) {
setDraft(null)
draftMeta = null
composer().draft = null
return
}
if (currentDraft.endLine !== undefined && currentDraft.endLine > max) {
setDraft(null)
draftMeta = null
composer().draft = null
}
},
),
@@ -340,8 +371,11 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
})
const annotationsForFile = (file: string): DiffLineAnnotation<AnnotationMeta>[] => {
const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta)
const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta, editMeta)
draftMeta = result.draftMeta
editMeta = result.editMeta
composer().draft = draft() ? draftMeta : null
composer().edit = editing() ? editMeta : null
return result.annotations
}
@@ -365,7 +399,10 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
if (draft()) return
const side: AnnotationSide = range.side === "deletions" ? "deletions" : "additions"
preserveScroll(() => {
setDraft({ file, side, line: range.start, endLine: range.end })
const next = { file, side, line: range.start, endLine: range.end }
draftMeta = { type: "draft", comment: null, ...next }
composer().draft = draftMeta
setDraft(next)
})
}
@@ -121,7 +121,7 @@ export const MultiModelSelector: Component<{
<Show when={isDataCollectedModel(model)}>
<Tooltip value={dataLabel()} placement="top">
<span class="am-mm-free-data-icon" aria-label={dataLabel()}>
<Icon name="warning" size="small" />
<Icon name="brain-circuit" size="small" />
</span>
</Tooltip>
</Show>
@@ -27,6 +27,7 @@ function insertReviewSpeechText(textarea: HTMLTextAreaElement, value: string): v
textarea.value = result.text
textarea.setSelectionRange(result.pos, result.pos)
textarea.dispatchEvent(new Event("input", { bubbles: true }))
textarea.focus()
}
@@ -24,6 +24,35 @@ export interface AnnotationMeta {
line: number
endLine?: number
editing?: boolean
text?: string
}
export type ReviewDraft = Pick<AnnotationMeta, "file" | "side" | "line" | "endLine">
export interface ReviewComposer {
draft: AnnotationMeta | null
edit: AnnotationMeta | null
}
export function createReviewComposer(): ReviewComposer {
return { draft: null, edit: null }
}
export function clearReviewComposer(composer: ReviewComposer): void {
composer.draft = null
composer.edit = null
}
export function reviewComposerDraft(composer: ReviewComposer): ReviewDraft | null {
const draft = composer.draft
if (!draft || draft.type !== "draft") return null
return { file: draft.file, side: draft.side, line: draft.line, endLine: draft.endLine }
}
export function reviewComposerEdit(composer: ReviewComposer): string | null {
const edit = composer.edit
if (!edit || edit.type !== "comment") return null
return edit.comment?.id ?? null
}
type SpeechDraft = Pick<AnnotationMeta, "file" | "side" | "line" | "endLine">
@@ -71,6 +100,14 @@ function focusWhenConnected(el: HTMLTextAreaElement): void {
requestAnimationFrame(tick)
}
// Keep composer text off the disposable annotation DOM without making each keystroke reactive.
function trackText(meta: AnnotationMeta, textarea: HTMLTextAreaElement, fallback = ""): void {
textarea.value = meta.text ?? fallback
textarea.addEventListener("input", () => {
meta.text = textarea.value
})
}
function makeIcon(pathData: string): SVGSVGElement {
const ns = "http://www.w3.org/2000/svg"
const svg = document.createElementNS(ns, "svg")
@@ -100,21 +137,48 @@ export function buildFileAnnotations(
file: string,
fileComments: ReviewComment[],
edit: string | null,
draft: { file: string; side: AnnotationSide; line: number; endLine?: number } | null,
draft: ReviewDraft | null,
draftMeta: AnnotationMeta | null,
): { annotations: DiffLineAnnotation<AnnotationMeta>[]; draftMeta: AnnotationMeta | null } {
const result: DiffLineAnnotation<AnnotationMeta>[] = fileComments.map((c) => ({
side: c.side,
lineNumber: c.line,
metadata: {
type: "comment" as const,
comment: c,
file: c.file,
side: c.side,
line: c.line,
editing: c.id === edit,
},
}))
editMeta: AnnotationMeta | null,
): {
annotations: DiffLineAnnotation<AnnotationMeta>[]
draftMeta: AnnotationMeta | null
editMeta: AnnotationMeta | null
} {
if (!edit) editMeta = null
const result: DiffLineAnnotation<AnnotationMeta>[] = fileComments.map((c) => {
if (c.id !== edit) {
return {
side: c.side,
lineNumber: c.line,
metadata: {
type: "comment" as const,
comment: c,
file: c.file,
side: c.side,
line: c.line,
},
}
}
if (
!editMeta ||
editMeta.comment?.id !== c.id ||
editMeta.file !== c.file ||
editMeta.side !== c.side ||
editMeta.line !== c.line
) {
editMeta = {
type: "comment",
comment: c,
file: c.file,
side: c.side,
line: c.line,
editing: true,
}
}
editMeta.comment = c
return { side: c.side, lineNumber: c.line, metadata: editMeta }
})
if (draft && draft.file === file) {
if (
@@ -135,7 +199,7 @@ export function buildFileAnnotations(
}
result.push({ side: draft.side, lineNumber: draft.line, metadata: draftMeta })
}
return { annotations: result, draftMeta }
return { annotations: result, draftMeta, editMeta }
}
export function buildReviewAnnotation(
@@ -158,6 +222,7 @@ export function buildReviewAnnotation(
textarea.className = "am-annotation-textarea"
textarea.rows = 3
textarea.placeholder = handlers.labels.placeholder
trackText(meta, textarea)
const actions = document.createElement("div")
actions.className = "am-annotation-actions"
@@ -226,7 +291,7 @@ export function buildReviewAnnotation(
const textarea = document.createElement("textarea")
textarea.className = "am-annotation-textarea"
textarea.rows = 3
textarea.value = comment.comment
trackText(meta, textarea, comment.comment)
const actions = document.createElement("div")
actions.className = "am-annotation-actions"
@@ -92,14 +92,21 @@ export const MessageList: Component<MessageListProps> = (props) => {
const boundary = () => session.revert()?.messageID
const turns = createMemo((prev: MessageTurn[] | undefined) =>
stableMessageTurns(messageTurns(session.messages(), boundary()), prev),
stableMessageTurns(
messageTurns(session.messages(), boundary(), (msg) => session.getParts(msg.id)),
prev,
),
)
const isEmpty = () => turns().length === 0 && !session.loading() && !boundary()
const recent = createMemo(() => recentSessions(session.sessions()))
const activeUserID = createMemo(() => getActiveUserMessageID(session.messages(), session.statusInfo()))
const queuedIDs = createMemo(() => new Set(queuedUserMessageIDs(session.messages(), session.statusInfo())))
const activeUserID = createMemo(() =>
getActiveUserMessageID(session.messages(), session.statusInfo(), (msg) => session.getParts(msg.id)),
)
const queuedIDs = createMemo(
() => new Set(queuedUserMessageIDs(session.messages(), session.statusInfo(), (msg) => session.getParts(msg.id))),
)
const [held, setHeld] = createSignal<{ sid: string; ids: Set<string> }>()
createEffect(() => {
const id = activeUserID()
@@ -96,7 +96,7 @@ export const ModelPreview: Component<Props> = (props) => {
<Show when={isDataCollectedModel(model())}>
<Tooltip value={dataLabel()} placement="top">
<span class="model-preview-free-data-icon" aria-label={dataLabel()}>
<Icon name="warning" size="small" />
<Icon name="brain-circuit" size="small" />
</span>
</Tooltip>
</Show>
@@ -132,7 +132,7 @@ export const ModelPreview: Component<Props> = (props) => {
<Show when={isDataCollectedModel(model())}>
<span class="model-preview-data-line" aria-label={dataLabel()}>
<Icon name="warning" size="small" />
<Icon name="brain-circuit" size="small" />
<span>- {dataLabel()}</span>
</span>
</Show>
@@ -636,7 +636,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
<Show when={activeCollectsData()}>
<Tooltip value={dataLabel()} placement="top">
<span class="model-selector-trigger-free-data" aria-label={dataLabel()}>
<Icon name="warning" size="small" />
<Icon name="brain-circuit" size="small" />
</span>
</Tooltip>
</Show>
@@ -836,7 +836,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
<Show when={isDataCollectedModel(model)}>
<Tooltip value={dataLabel()} placement="top">
<span class="model-selector-free-data-icon" aria-label={dataLabel()}>
<Icon name="warning" size="small" />
<Icon name="brain-circuit" size="small" />
</span>
</Tooltip>
</Show>
@@ -42,27 +42,52 @@ function partials(messages: Message[]): MessageTurn[] {
.map(partial)
}
export function messageTurns(messages: Message[], boundary?: string): MessageTurn[] {
function isCompact(msg: Message, parts?: (msg: Message) => Message["parts"]) {
return msg.role === "user" && (parts?.(msg) ?? msg.parts)?.some((part) => part.type === "compaction")
}
function target(messages: Message[], index: number, id: string, parts?: (msg: Message) => Message["parts"]) {
const parent = messages.findIndex((msg) => msg.id === id)
for (let i = index - 1; i > parent; i -= 1) {
const msg = messages[i]
if (msg && isCompact(msg, parts)) return msg.id
}
return id
}
export function messageTurns(
messages: Message[],
boundary?: string,
parts?: (msg: Message) => Message["parts"],
): MessageTurn[] {
const result: MessageTurn[] = []
const lead: Message[] = []
const by = new Map<string, MessageTurn>()
const by = new Map<string, { turn: MessageTurn; index: number }>()
let compact: { turn: MessageTurn; index: number } | undefined
for (const msg of messages) {
if (msg.role === "user") {
if (boundary && msg.id >= boundary) break
const turn = { id: msg.id, user: msg, assistant: [] }
const item = { turn, index: result.length }
result.push(turn)
by.set(msg.id, turn)
by.set(msg.id, item)
if (isCompact(msg, parts)) compact = item
continue
}
if (msg.role !== "assistant") continue
const turn = msg.parentID ? by.get(msg.parentID) : undefined
if (turn) {
const parent = msg.parentID ? by.get(msg.parentID) : undefined
if (parent) {
const turn = compact && parent.index < compact.index ? compact.turn : parent.turn
turn.assistant.push(msg)
continue
}
if (msg.parentID) {
if (compact) {
compact.turn.assistant.push(msg)
continue
}
lead.push(msg)
continue
}
@@ -78,8 +103,12 @@ export function messageTurns(messages: Message[], boundary?: string): MessageTur
return [...partials(lead), ...result]
}
export function visibleMessages(messages: Message[], boundary?: string): Message[] {
return messageTurns(messages, boundary).flatMap((turn) =>
export function visibleMessages(
messages: Message[],
boundary?: string,
parts?: (msg: Message) => Message["parts"],
): Message[] {
return messageTurns(messages, boundary, parts).flatMap((turn) =>
turn.partial ? turn.assistant : [turn.user, ...turn.assistant],
)
}
@@ -106,7 +135,7 @@ export function stableMessageTurns(next: MessageTurn[], prev: MessageTurn[] = []
})
}
function active(messages: Message[], status: SessionStatusInfo) {
function active(messages: Message[], status: SessionStatusInfo, parts?: (msg: Message) => Message["parts"]) {
let latest = true
for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i]
@@ -118,8 +147,9 @@ function active(messages: Message[], status: SessionStatusInfo) {
if (msg.error) continue
if (msg.finish && !resumable) continue
if (!msg.parentID) break
const parent = messages.find((item) => item.id === msg.parentID)
if (!parent) return msg.parentID
const id = target(messages, i, msg.parentID, parts)
const parent = messages.find((item) => item.id === id)
if (!parent) return id
if (parent.role === "user") return parent.id
break
}
@@ -127,20 +157,20 @@ function active(messages: Message[], status: SessionStatusInfo) {
return undefined
}
function done(messages: Message[]) {
function done(messages: Message[], parts?: (msg: Message) => Message["parts"]) {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i]
if (!msg || msg.role !== "assistant") continue
if (typeof msg.time?.completed === "number") return msg.parentID
if (msg.error) return msg.parentID
if (msg.finish && !["tool-calls", "unknown"].includes(msg.finish)) return msg.parentID
if (!msg || msg.role !== "assistant" || !msg.parentID) continue
if (typeof msg.time?.completed === "number") return target(messages, i, msg.parentID, parts)
if (msg.error) return target(messages, i, msg.parentID, parts)
if (msg.finish && !["tool-calls", "unknown"].includes(msg.finish)) return target(messages, i, msg.parentID, parts)
}
return undefined
}
function pending(messages: Message[]) {
function pending(messages: Message[], parts?: (msg: Message) => Message["parts"]) {
const users = messages.filter((msg) => msg.role === "user")
const id = done(messages)
const id = done(messages, parts)
if (!id) return users[0]?.id
const idx = users.findIndex((msg) => msg.id === id)
@@ -149,23 +179,31 @@ function pending(messages: Message[]) {
// Find the user message whose turn the server is actively processing.
// Any user message after this one is "queued" (waiting for its turn).
export function activeUserMessageID(messages: Message[], status: SessionStatusInfo) {
const id = active(messages, status)
export function activeUserMessageID(
messages: Message[],
status: SessionStatusInfo,
parts?: (msg: Message) => Message["parts"],
) {
const id = active(messages, status, parts)
if (id) return id
if (status.type === "idle") return undefined
return pending(messages)
return pending(messages, parts)
}
export function queuedUserMessageIDs(messages: Message[], status: SessionStatusInfo) {
export function queuedUserMessageIDs(
messages: Message[],
status: SessionStatusInfo,
parts?: (msg: Message) => Message["parts"],
) {
if (status.type === "idle") return []
const users = messages.filter((msg) => msg.role === "user")
const running = active(messages, status)
const running = active(messages, status, parts)
if (running) {
const idx = users.findIndex((msg) => msg.id === running)
if (idx < 0) return users.map((msg) => msg.id)
return users.slice(idx + 1).map((msg) => msg.id)
}
const id = pending(messages)
const id = pending(messages, parts)
const idx = id ? users.findIndex((msg) => msg.id === id) : -1
if (idx < 0) return []
return users.slice(idx + 1).map((msg) => msg.id)
@@ -2304,7 +2304,9 @@ export const SessionProvider: ParentComponent = (props) => {
const userMessages = createMemo(() => messages().filter((m) => m.role === "user"))
function visible(sessionID: string) {
return filterVisibleMessages(store.messages[sessionID] ?? [], store.sessions[sessionID]?.revert?.messageID)
return filterVisibleMessages(store.messages[sessionID] ?? [], store.sessions[sessionID]?.revert?.messageID, (msg) =>
getParts(msg.id),
)
}
const revert = createMemo(() => {
@@ -73,7 +73,14 @@ export interface StepFinishPart extends BasePart {
}
}
export type Part = TextPart | FilePart | ToolPart | ReasoningPart | StepStartPart | StepFinishPart
export interface CompactionPart extends BasePart {
type: "compaction"
auto: boolean
overflow?: boolean
tail_start_id?: string
}
export type Part = TextPart | FilePart | ToolPart | ReasoningPart | StepStartPart | StepFinishPart | CompactionPart
// Part delta for streaming updates
export interface PartDelta {
@@ -188,35 +188,49 @@ export const TranscriptionResponse = Schema.Struct({
usage: Schema.optional(Schema.Unknown),
})
export const CloudMessage = Schema.Struct({
info: Schema.Struct({
id: Schema.String,
sessionID: Schema.String,
role: Schema.Literals(["user", "assistant"]),
time: Schema.Struct({
created: Schema.Finite,
completed: Schema.optional(Schema.Finite),
}),
const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown)
export const CloudMessage = Schema.StructWithRest(
Schema.Struct({
info: Schema.StructWithRest(
Schema.Struct({
id: Schema.String,
sessionID: Schema.String,
role: Schema.Literals(["user", "assistant"]),
time: Schema.Struct({
created: Schema.Finite,
completed: Schema.optional(Schema.Finite),
}),
}),
[UnknownRecord],
),
parts: Schema.Array(
Schema.StructWithRest(
Schema.Struct({
id: Schema.String,
sessionID: Schema.String,
messageID: Schema.String,
type: Schema.String,
}),
[UnknownRecord],
),
),
}),
parts: Schema.Array(
Schema.Struct({
id: Schema.String,
sessionID: Schema.String,
messageID: Schema.String,
type: Schema.String,
}),
),
})
[UnknownRecord],
)
export const CloudSessionData = Schema.Struct({
info: Schema.Struct({
id: Schema.String,
title: Schema.String,
time: Schema.Struct({
created: Schema.Finite,
updated: Schema.Finite,
info: Schema.StructWithRest(
Schema.Struct({
id: Schema.String,
title: Schema.String,
time: Schema.Struct({
created: Schema.Finite,
updated: Schema.Finite,
}),
}),
}),
[UnknownRecord],
),
messages: Schema.Array(CloudMessage),
})
@@ -0,0 +1,66 @@
import type { MessageV2 } from "@/session/message-v2"
const chronology = new WeakMap<MessageV2.WithParts, number>()
export namespace KiloSessionMessageOrder {
/** Preserve chronological order before model-facing projections rearrange messages. */
export function annotate(msgs: MessageV2.WithParts[]) {
for (const [index, msg] of msgs.entries()) chronology.set(msg, index)
return msgs
}
export function compare(a: MessageV2.WithParts, b: MessageV2.WithParts, indexA = -1, indexB = -1) {
if (a.info.time.created !== b.info.time.created) return a.info.time.created - b.info.time.created
const sequenceA = chronology.get(a)
const sequenceB = chronology.get(b)
if (sequenceA !== undefined && sequenceB !== undefined && sequenceA !== sequenceB) return sequenceA - sequenceB
return indexA - indexB
}
/** Derive active messages by chronology while keeping queued tasks in model-facing projection order. */
export function latest(msgs: MessageV2.WithParts[]) {
let user: MessageV2.WithParts | undefined
let assistant: MessageV2.WithParts | undefined
let finished: MessageV2.WithParts | undefined
let userIndex = -1
let assistantIndex = -1
let finishedIndex = -1
for (const [index, msg] of msgs.entries()) {
const info = msg.info
if (info.role === "user" && (!user || compare(msg, user, index, userIndex) > 0)) {
user = msg
userIndex = index
}
if (info.role === "assistant" && (!assistant || compare(msg, assistant, index, assistantIndex) > 0)) {
assistant = msg
assistantIndex = index
}
if (info.role === "assistant" && info.finish && (!finished || compare(msg, finished, index, finishedIndex) > 0)) {
finished = msg
finishedIndex = index
}
}
const pivot = msgs.findLastIndex((msg) => msg.info.role === "assistant" && msg.info.finish)
const tasks = msgs
.slice(pivot + 1)
.reverse()
.flatMap((msg) =>
msg.parts.filter(
(part): part is MessageV2.CompactionPart | MessageV2.SubtaskPart =>
part.type === "compaction" || part.type === "subtask",
),
)
return {
user: user?.info.role === "user" ? user.info : undefined,
assistant: assistant?.info.role === "assistant" ? assistant.info : undefined,
finished: finished?.info.role === "assistant" ? finished.info : undefined,
userMessage: user,
assistantMessage: assistant,
finishedMessage: finished,
tasks,
}
}
}
@@ -12,6 +12,7 @@ import type { SessionStatus } from "@/session/status"
import { Flag } from "@opencode-ai/core/flag/flag"
import { PlanFollowup } from "@/kilocode/plan-followup"
import { KiloSession } from "@/kilocode/session"
import { KiloSessionMessageOrder } from "@/kilocode/session/message-order"
import { Permission } from "@/permission"
import { environmentDetails, type EditorContext } from "@/kilocode/editor-context"
import { Identifier } from "@/id/id"
@@ -342,14 +343,18 @@ export namespace KiloSessionPrompt {
* `msgs`, `msgs` is returned unchanged.
*/
export function trimBeforeLastSummary(msgs: MessageV2.WithParts[]): MessageV2.WithParts[] {
for (let i = msgs.length - 1; i >= 0; i--) {
const info = msgs[i].info
if (info.role !== "assistant" || info.summary !== true || !info.finish || info.error) continue
const parentIdx = msgs.findIndex((m) => m.info.id === info.parentID)
if (parentIdx === -1) return msgs
return parentIdx === 0 ? msgs : msgs.slice(parentIdx)
}
return msgs
const summary = msgs.reduce<{ msg: MessageV2.WithParts; index: number } | undefined>((latest, msg, index) => {
const info = msg.info
if (info.role !== "assistant" || info.summary !== true || !info.finish || info.error) return latest
if (!latest || KiloSessionMessageOrder.compare(msg, latest.msg, index, latest.index) > 0) return { msg, index }
return latest
}, undefined)
if (!summary) return msgs
const info = summary.msg.info
if (info.role !== "assistant") return msgs
const parentIdx = msgs.findIndex((m) => m.info.id === info.parentID)
if (parentIdx === -1) return msgs
return parentIdx === 0 ? msgs : msgs.slice(parentIdx)
}
/**
@@ -24,6 +24,7 @@ import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { SessionNetwork } from "./network" // kilocode_change
import { CodexAuthExpiredError } from "@/kilocode/provider/codex-refresh" // kilocode_change
import { KiloSessionMessageOrder } from "@/kilocode/session/message-order" // kilocode_change
import { Effect, Schema, Types } from "effect"
import { zod, ZodOverride } from "@/util/effect-zod"
import { NonNegativeInt, withStatics } from "@/util/schema"
@@ -1218,6 +1219,7 @@ export function filterCompacted(msgs: Iterable<WithParts>) {
completed.add(msg.info.parentID)
}
result.reverse()
KiloSessionMessageOrder.annotate(result) // kilocode_change - preserve chronology before retained-tail projection
const compactionIndex = result.findLastIndex(
(msg) =>
msg.info.role === "user" &&
+17 -16
View File
@@ -2,6 +2,7 @@ import path from "path"
import os from "os"
import fs from "fs/promises"
import { KiloSessionPrompt } from "@/kilocode/session/prompt" // kilocode_change
import { KiloSessionMessageOrder } from "@/kilocode/session/message-order" // kilocode_change
import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" // kilocode_change
import { KiloSession } from "@/kilocode/session" // kilocode_change
import { KiloCostPropagation } from "@/kilocode/session/cost-propagation" // kilocode_change
@@ -1496,19 +1497,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
msgs = KiloSessionPromptQueue.scope(sessionID, msgs) // kilocode_change - hide later queued prompts
msgs = KiloSessionPrompt.trimBeforeLastSummary(msgs) // kilocode_change - trim on any completed summary (e.g. manual /compact against a text user)
let lastUser: MessageV2.User | undefined
let lastAssistant: MessageV2.Assistant | undefined
let lastFinished: MessageV2.Assistant | undefined
let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = []
for (let i = msgs.length - 1; i >= 0; i--) {
const msg = msgs[i]
if (!lastUser && msg.info.role === "user") lastUser = msg.info
if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info
if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info
if (lastUser && lastFinished) break
const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask")
if (task && !lastFinished) tasks.push(...task)
}
// kilocode_change start - select loop state by chronology after retained-tail projection
const latest = KiloSessionMessageOrder.latest(msgs)
const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = latest
// kilocode_change end
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
@@ -1516,6 +1507,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const lastAssistantMsg = msgs.findLast(
(msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,
)
// kilocode_change start - compare chronology, not generated IDs
const userBeforeAssistant =
latest.userMessage &&
latest.assistantMessage &&
KiloSessionMessageOrder.compare(latest.userMessage, latest.assistantMessage) < 0
// kilocode_change end
// kilocode_change start - carry local review command marker into LLM telemetry
const telemetry =
KiloSessionProcessor.extractReviewTelemetry(
@@ -1537,7 +1534,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
lastAssistant?.finish &&
hasToolCalls &&
lastAssistant.parentID === lastUser.id &&
lastUser.id < lastAssistant.id &&
userBeforeAssistant &&
KiloSessionPrompt.shouldAskPlanFollowup({ messages: msgs, abort: AbortSignal.any([]) })
) {
const action = yield* Effect.promise((signal) =>
@@ -1554,7 +1551,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
!["tool-calls"].includes(lastAssistant.finish) &&
!hasToolCalls &&
lastAssistant.parentID === lastUser.id && // kilocode_change - unrelated later assistants do not answer this turn
lastUser.id < lastAssistant.id
userBeforeAssistant // kilocode_change - compare chronology, not generated IDs
) {
// kilocode_change start - ask follow-up when plan_exit tool was called
const action = yield* Effect.promise((signal) =>
@@ -1690,7 +1687,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (step > 1 && lastFinished) {
for (const m of msgs) {
if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue
// kilocode_change start - compare chronology, not generated IDs
const finishedBeforeMessage =
latest.finishedMessage && KiloSessionMessageOrder.compare(latest.finishedMessage, m) < 0
if (m.info.role !== "user" || !finishedBeforeMessage) continue
// kilocode_change end
for (const p of m.parts) {
if (p.type !== "text" || p.ignored || p.synthetic) continue
if (!p.text.trim()) continue
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { CloudSessionData } from "../../src/kilocode/server/httpapi/groups/kilo-gateway"
describe("cloud session HTTP schema", () => {
test("preserves transcript fields needed by the VS Code preview", () => {
const input = {
info: {
id: "ses_cloud",
title: "Cloud transcript",
slug: "cloud-transcript",
time: { created: 1, updated: 2 },
},
messages: [
{
info: {
id: "msg_user",
sessionID: "ses_cloud",
role: "user" as const,
agent: "code",
time: { created: 3 },
},
parts: [
{
id: "prt_text",
sessionID: "ses_cloud",
messageID: "msg_user",
type: "text",
text: "Show this cloud message",
},
],
},
],
}
expect(Schema.encodeUnknownSync(CloudSessionData)(input)).toEqual(input)
})
})
@@ -4,6 +4,7 @@
import { describe, expect, test } from "bun:test"
import { KiloSessionPrompt } from "../../src/kilocode/session/prompt"
import { KiloSessionMessageOrder } from "../../src/kilocode/session/message-order"
import { MessageV2 } from "../../src/session/message-v2"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
@@ -68,6 +69,29 @@ function syntheticTextPart(messageID: string, text: string, partID = "p_syn_" +
}
}
function compactionPart(messageID: string, tailStartID: string): MessageV2.CompactionPart {
return {
id: PartID.make("p_compact_" + messageID),
sessionID,
messageID: MessageID.make(messageID),
type: "compaction",
auto: false,
tail_start_id: MessageID.make(tailStartID),
}
}
function subtaskPart(messageID: string): MessageV2.SubtaskPart {
return {
id: PartID.make("p_subtask_" + messageID),
sessionID,
messageID: MessageID.make(messageID),
type: "subtask",
prompt: "continue",
description: "Continue queued task",
agent: "test",
}
}
function filePart(
messageID: string,
mime: string,
@@ -149,6 +173,11 @@ function assistant(
return { info: assistantInfo(id, parentID, opts), parts }
}
function created(msg: MessageV2.WithParts, time: number) {
msg.info.time.created = time
return msg
}
const apiError = new MessageV2.APIError({
message: "boom",
isRetryable: true,
@@ -178,6 +207,54 @@ describe("KiloSessionPrompt.hasCompletedSummary", () => {
})
})
describe("MessageV2.latest", () => {
test("selects chronological state after retained pre-compaction tail", () => {
const msgs = MessageV2.filterCompacted([
created(assistant("msg_summary", "msg_compact", [], { summary: true, finish: "end_turn" }), 4),
created(user("msg_compact", [compactionPart("msg_compact", "msg_tail")]), 3),
created(assistant("msg_tail_reply", "msg_tail", [], { finish: "end_turn" }), 2),
created(user("msg_tail", [textPart("msg_tail", "historical retained prompt")]), 1),
])
expect(msgs.map((m) => m.info.id)).toEqual([
MessageID.make("msg_compact"),
MessageID.make("msg_summary"),
MessageID.make("msg_tail"),
MessageID.make("msg_tail_reply"),
])
const state = KiloSessionMessageOrder.latest(msgs)
expect(state.user?.id).toBe(MessageID.make("msg_compact"))
expect(state.assistant?.id).toBe(MessageID.make("msg_summary"))
expect(state.finished?.id).toBe(MessageID.make("msg_summary"))
expect(state.tasks).toEqual([])
})
test("keeps queued subtasks moved after a chronologically later assistant", () => {
const part = subtaskPart("msg_queued")
const active = created(user("msg_active"), 1)
const queued = created(user("msg_queued", [part]), 2)
const done = created(assistant("msg_done", "msg_active", [], { finish: "end_turn" }), 3)
KiloSessionMessageOrder.annotate([active, queued, done])
const state = KiloSessionMessageOrder.latest([active, done, queued])
expect(state.user?.id).toBe(MessageID.make("msg_queued"))
expect(state.finished?.id).toBe(MessageID.make("msg_done"))
expect(state.tasks).toEqual([part])
})
test("processes projected tasks in queue order", () => {
const first = subtaskPart("msg_first")
const second = subtaskPart("msg_second")
const msgs = [created(user("msg_first", [first]), 1), created(user("msg_second", [second]), 2)]
const state = KiloSessionMessageOrder.latest(msgs)
expect(state.user?.id).toBe(MessageID.make("msg_second"))
expect(state.tasks).toEqual([second, first])
expect(state.tasks.pop()).toBe(first)
})
})
describe("KiloSessionPrompt.trimBeforeLastSummary", () => {
test("returns input unchanged when no summary present", () => {
const msgs = [user("msg_u1"), assistant("msg_a1", "msg_u1", [], { finish: "end_turn" })]
@@ -228,6 +305,31 @@ describe("KiloSessionPrompt.trimBeforeLastSummary", () => {
])
})
test("keeps the newest summary when retained history contains an older summary", () => {
const filtered = MessageV2.filterCompacted([
created(assistant("msg_s8", "msg_c7", [], { summary: true, finish: "end_turn" }), 8),
created(user("msg_c7", [compactionPart("msg_c7", "msg_u1")]), 7),
created(assistant("msg_a6", "msg_u5", [], { finish: "end_turn" }), 6),
created(user("msg_u5"), 5),
created(assistant("msg_s4", "msg_c3", [], { summary: true, finish: "end_turn" }), 4),
created(user("msg_c3", [compactionPart("msg_c3", "msg_u1")]), 3),
created(assistant("msg_a2", "msg_u1", [], { finish: "end_turn" }), 2),
created(user("msg_u1"), 1),
])
expect(filtered.map((m) => m.info.id)).toEqual([
MessageID.make("msg_c7"),
MessageID.make("msg_s8"),
MessageID.make("msg_u1"),
MessageID.make("msg_a2"),
MessageID.make("msg_c3"),
MessageID.make("msg_s4"),
MessageID.make("msg_u5"),
MessageID.make("msg_a6"),
])
expect(KiloSessionPrompt.trimBeforeLastSummary(filtered)).toBe(filtered)
})
test("ignores errored and unfinished summaries when choosing boundary", () => {
const msgs = [
user("msg_u1"),
+4 -9
View File
@@ -12025,8 +12025,7 @@
"additionalProperties": false
}
},
"required": ["id", "title", "time"],
"additionalProperties": false
"required": ["id", "title", "time"]
},
"messages": {
"type": "array",
@@ -12060,8 +12059,7 @@
"additionalProperties": false
}
},
"required": ["id", "sessionID", "role", "time"],
"additionalProperties": false
"required": ["id", "sessionID", "role", "time"]
},
"parts": {
"type": "array",
@@ -12081,13 +12079,11 @@
"type": "string"
}
},
"required": ["id", "sessionID", "messageID", "type"],
"additionalProperties": false
"required": ["id", "sessionID", "messageID", "type"]
}
}
},
"required": ["info", "parts"],
"additionalProperties": false
"required": ["info", "parts"]
}
}
},
@@ -12170,7 +12166,6 @@
}
},
"required": ["id", "title", "time"],
"additionalProperties": false,
"description": "Imported session info"
}
}
+1
View File
@@ -36,6 +36,7 @@ const active = new Set([
"check-org-member.yml",
"close-issues.yml",
"close-stale-prs.yml",
"codeql.yml",
"containers.yml",
"docs-build.yml",
"docs-check-links.yml",