Compare commits

..
36 Commits
Author SHA1 Message Date
David Kaya fa355197cb chore: release v0.0.29 2026-04-16 14:48:09 +02:00
David KayaandCopilot 5f263002f0 fix: pass mainWindow argument to showAndFocusWindow in second-instance handler
The showAndFocusWindow function signature was updated to require a
mainWindow parameter, but the call site in the second-instance handler
was not updated, causing a TypeScript compilation error that broke CI.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 14:43:41 +02:00
David Kaya 1376de4148 chore: release v0.0.28 2026-04-16 14:17:21 +02:00
David Kaya 5ce6bb4b0a Merge branch 'agents/tray-icon-main-window-fix' 2026-04-16 14:16:33 +02:00
David KayaandCopilot 47d3fb0a29 fix: tray icon click opens main window reliably after quick prompt use
showAndFocusWindow() used BrowserWindow.getAllWindows()[0] to find the
main window, which breaks once the quick prompt window is created —
the array order is not guaranteed and windows[0] may resolve to the
quick prompt instead, causing the show/focus calls to target a hidden,
frameless window.

Accept the main window reference as an explicit parameter and guard
against destroyed windows with isDestroyed().

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 14:00:30 +02:00
David Kaya 4da31cbdd6 Merge branch 'agents/single-instance-app-launch' 2026-04-16 13:56:19 +02:00
David KayaandCopilot f15323e37e feat: enforce single-instance app launch
Use Electron's requestSingleInstanceLock() to prevent opening
multiple instances. When a second instance is attempted, the
existing window is restored and focused instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 13:54:22 +02:00
David KayaandCopilot f8fe9d866e fix: use platform-native paths in release tests for cross-OS compatibility
The release workflow tests hardcoded Windows-style backslash paths
(C:\workspace\...) which caused path.relative() to produce incorrect
results on Linux and macOS, where backslashes are not path separators.

Replace hardcoded paths with resolve()/join() calls that produce
platform-native paths, fixing CI failures on Linux and macOS.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 13:53:49 +02:00
David Kaya 6c24754749 chore: release v0.0.27 2026-04-16 12:57:19 +02:00
David Kaya 2a7c627008 Merge branch 'agents/dotnet-aspire-opentelemetry-explorer' 2026-04-16 11:57:06 +02:00
David KayaandCopilot 3a3e1d5eab fix: correct OTEL instrumentation wiring for agent traces
- Fix ActivitySource name from 'Microsoft.Agents.AI' to
  'Experimental.Microsoft.Agents.AI' matching the Agent Framework default
- Wrap agents with UseOpenTelemetry() via AIAgentBuilder so agent
  invocation spans are emitted to the configured OTLP collector
- Add proper disposal of OpenTelemetryAgent wrappers via
  SyncDisposableAdapter bridging IDisposable to IAsyncDisposable
- Enable workflow-level telemetry on WorkflowRunner custom graph builds
  via WithOpenTelemetry() (HandoffWorkflowBuilder and
  GroupChatWorkflowBuilder do not expose this API — framework limitation)
- Fix settings panel help text to be user-facing instead of dev jargon
- Update test assertion for corrected ActivitySource name

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 11:52:13 +02:00
David Kaya 9a9332369b Merge branch 'agents/dotnet-aspire-opentelemetry-explorer' 2026-04-16 11:25:51 +02:00
David KayaandCopilot 67fb950e61 feat: add OpenTelemetry settings UI and sidecar environment wiring
Add a global Telemetry section in the settings panel with:
- Toggle to enable/disable OTLP export
- Text input for the OTLP endpoint URL (default: http://localhost:4317)
- Guidance note about using \un run aspire\ for local testing

Wire the setting through the full stack:
- OpenTelemetrySettings type in shared domain with normalization
- IPC channel, preload binding, and handler for persistence
- SidecarClient forwards settings to createSidecarEnvironment
- Sidecar environment injects OTEL_EXPORTER_OTLP_ENDPOINT when enabled
- Settings loaded from workspace.json on startup and on change

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 11:10:23 +02:00
David KayaandCopilot f7d376fc41 feat: add Aspire dashboard OTEL workflow
Add OTLP export wiring for the sidecar and dev scripts to launch the standalone Aspire Dashboard during development.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 10:39:36 +02:00
David KayaandCopilot 59ea028a24 feat: add release helper
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 09:30:17 +02:00
David KayaandCopilot c7910e6e4b fix: remove streaming text boundary heuristics that injected spurious spaces
The StreamingTextMerger (C#) and mergeStreamingText (TypeScript) contained
custom word-boundary heuristics that inserted spaces between streaming deltas
based on incorrect assumptions. Two bugs caused mid-word spaces:

1. Unconditional space insertion when incoming delta started with uppercase or
   digit characters, breaking acronyms (MDA -> M DA, UAG -> UA G)
2. LooksLikeWordBoundary using global token counts that fired on nearly every
   mid-word token split (writable -> wr itable, fragile -> frag ile)

Both upstream projects (Copilot SDK and Agent Framework) use simple string
concatenation for delta accumulation. LLM streaming tokens natively include
whitespace when needed, so no boundary detection is required.

Removed all boundary heuristic methods and replaced with plain concatenation.
Kept overlap detection and snapshot handling for event redelivery scenarios.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 18:55:34 +02:00
David Kaya 418946b854 chore: bump version to 0.0.26 2026-04-15 11:15:20 +02:00
David KayaandCopilot 10fcaf2b92 fix: use theme-aware background for chat code blocks
Replace hard-coded dark hex color (bg-[#0d0d10]) with the theme-aware
bg-surface-1 token so the code block background correctly adapts to
light mode. The other zinc-based utility classes already respond to
the light mode CSS variable overrides.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 10:57:04 +02:00
David KayaandCopilot 34fb5420a4 fix: show activity panels for all turns, not just the last one
The displayItems logic only injected orphan run panels for the most
recent user message's run. Previous turns whose thinking messages
were not produced or were lost during reclassification silently
dropped their activity panels.

Now iterate all unconsumed runs and splice a turn-activity panel
after each trigger user message, ensuring every turn with a run
gets a visible activity panel in the chat transcript.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 10:29:19 +02:00
David KayaandCopilot 05b3771544 fix: accurate elapsed timing and UX polish for activity panel
- Fix useElapsedTimer to use completedAt instead of Date.now() for
  finished runs, preventing wildly inaccurate durations (e.g. 9802m)
  when reopening old sessions
- Extract formatElapsedMs as a shared pure formatter; reuse in
  SubagentActivityCard
- Standardize icon column alignment across all activity row types
  using consistent w-4 + h-[18px] icon containers
- Redesign collapsed header: status dot with pulse animation, bolder
  status label, tabular-nums on elapsed time, cleaner hierarchy
- Polish expanded stream: timeline spine connecting activity items,
  more prominent intent dividers with pill-style background,
  tightened vertical rhythm
- Add unit tests for formatElapsedMs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 09:11:38 +02:00
David Kaya 9243ec10a2 Merge branch 'agents/prepared-parrot' 2026-04-15 08:42:58 +02:00
David KayaandCopilot 1f49436d8a fix: serialize sidecar cleanup in batch delete to avoid requestId collision
Multiple concurrent sidecar.deleteSession() calls used
requestId: delete-session-\ which collides when two
fire in the same millisecond. The second overwrites the first in
the pending map, causing the first promise to never resolve and
Promise.allSettled to hang indefinitely — producing Electron's
'reply was never sent' error.

Run cleanup sequentially instead. Also add .catch() to rm() calls
for robustness.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 08:42:55 +02:00
David Kaya 61b5788e78 Merge branch 'agents/confidential-tyrannosaurus' 2026-04-15 08:35:31 +02:00
David KayaandCopilot 875c6a3706 chore: upgrade copilot preview package
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 08:34:46 +02:00
David KayaandCopilot b4aaf62179 chore: upgrade agent-framework to 1.1.0
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 08:31:54 +02:00
David Kaya 4ffd6ad8d4 Merge branch 'agents/prepared-parrot' 2026-04-15 08:27:52 +02:00
David KayaandCopilot c22bd32f97 fix: wire modifier key detection into SessionItem click handler
Ctrl+Click (⌘+Click on Mac) and Shift+Click were not triggering
multi-select mode because modifier key detection in handleSessionClick
was never called from the actual click chain.

Root cause:
- SessionItem.handleClick only checked isSelecting state, never
  inspected modifier keys on the mouse event
- ProjectGroup passed onSessionSelect directly (plain navigation),
  bypassing handleSessionClick entirely
- Search results called handleSessionClick with a hardcoded fake event
  where all modifier keys were false

Fix:
- Move modifier key detection into SessionItem.handleClick, which
  receives the real React.MouseEvent with accurate modifier state
- Add onEnterSelectionMode and onShiftSelect props to SessionItem
- Thread new callbacks through ProjectGroup
- Remove dead handleSessionClick function from Sidebar
- Fix search results to use the new callback-based approach

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 08:27:44 +02:00
David Kaya dad9769fa1 Merge branch 'agents/prepared-parrot' 2026-04-15 08:15:01 +02:00
David Kaya 56e2f8669c Merge branch 'agents/abundant-squirrel'
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-04-15 08:14:58 +02:00
David Kaya d6008da0af Merge branch 'main' into agents/prepared-parrot 2026-04-15 08:14:33 +02:00
David KayaandCopilot 575aca360a feat: batch session archive and delete with multi-select UI
Add multi-select mode to the sidebar session list, enabling users to
archive, restore, or delete multiple sessions at once.

UX:
- Ctrl+Click (Cmd+Click on Mac) any session to enter multi-select mode
- Animated checkboxes stagger-reveal across all visible sessions
- Click to toggle, Shift+Click for range selection
- Running sessions are excluded from selection (safety guard)
- Floating action bar slides up with count pill, Archive, Delete, Cancel
- Batch delete shows a confirmation dialog with hold-to-confirm for 3+
  sessions and a scrollable list of session titles
- Batch archive shows an undo toast with 5-second auto-dismiss
- Escape key or Cancel button exits multi-select mode

Backend:
- batchSetSessionsArchived IPC: single load/mutate/persist cycle for N
  sessions instead of N sequential calls
- batchDeleteSessions IPC: parallel cleanup of scratchpad dirs and SDK
  sessions, then single workspace persist

Accessibility:
- Action bar: role=toolbar, aria-label
- Checkboxes: role=checkbox, aria-checked, keyboard-activatable
- Selection count: aria-live=polite
- Delete dialog: role=alertdialog, aria-modal, focus trap

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 07:12:45 +02:00
David KayaandCopilot 649eeddff3 feat: add configurable hotkey for Quick Prompt in settings
Add a HotkeyRecorder UI component that lets users click to enter
recording mode, press a modifier+key combination, and immediately
save it as the new Quick Prompt global shortcut.

- New HotkeyRecorder component in ui/ with live modifier preview,
  animated recording indicator, click-outside/Escape to cancel,
  platform-aware key symbol display, and disabled state support
- Integrate into QuickPromptSettingsSection with separate enable
  toggle and recorder rows
- Recorder is disabled when the global hotkey toggle is off
- Existing GlobalHotkeyService.update() re-registers automatically

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 07:02:27 +02:00
David Kaya 521be0de3c Merge branch 'agents/embarrassing-trout' 2026-04-15 06:59:00 +02:00
David KayaandCopilot fdfa6f3046 fix: remove shadow causing gradient rectangle and add keyboard navigation to quick prompt model picker
Remove shadow-2xl shadow-black/50 from the ModelSelector dropdown. On
transparent Electron windows (Windows), box-shadows paint onto the
transparent canvas and create a visible rectangle behind the UI — the
panel itself already avoided this with a comment explaining why.

Add full arrow-key navigation to the model picker listbox: ArrowUp/Down
cycle through options, Enter/Space selects, Home/End jump to extremes,
Escape closes. The container auto-focuses on mount so keyboard input
works immediately after opening via Tab+Enter. Mouse hover also syncs
the focused index for consistent visual feedback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 06:57:10 +02:00
David KayaandCopilot 0e9d77c745 feat: allow saved workspace agents in the workflow editor
Add the ability to select from saved workspace agents when configuring
agent nodes in the workflow editor. Previously, adding an agent node
always created a new inline agent with empty fields.

Changes:
- Thread workspaceAgents prop through SettingsPanel → WorkflowEditor →
  WorkflowGraphInspector → AgentNodeInspector
- Add Inline/Saved Agent source toggle in the agent node inspector
- Add workspace agent picker dropdown with stale-agent detection
- Add override-aware fields that show inherited values from the saved
  agent with per-field reset buttons and visual indicators
- Support clean bidirectional switching between inline and linked modes
- Show saved workspace agents as directly-addable items in the node
  palette with link icon, separated from the generic New Agent button
- Add handleAddWorkspaceAgentNode to create pre-linked agent nodes

The underlying data model (AgentNodeConfig.workspaceAgentId, overrides)
and resolution logic (resolveWorkflowAgentNode) already existed but were
never exposed in the UI.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 16:29:07 +02:00
David KayaandCopilot bed6427dfc fix: eliminate quick prompt panel flash on show
Add animation-fill-mode 'both' to .qp-panel-enter so the from-keyframe
(opacity: 0) applies immediately on mount, preventing the single-frame
flash of the opaque surface background before the entrance animation
begins.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 11:17:23 +02:00
52 changed files with 3508 additions and 356 deletions
+5 -3
View File
@@ -37,6 +37,7 @@ flowchart LR
Git[Local git repositories]
Sidecar[.NET sidecar]
Copilot[GitHub Copilot CLI<br/>+ agent runtime]
Telemetry[OTLP collector<br/>Aspire Dashboard]
OS[Native windowing<br/>and desktop integration]
User --> Renderer
@@ -46,6 +47,7 @@ flowchart LR
Main <--> Git
Main <--> Sidecar
Sidecar <--> Copilot
Sidecar --> Telemetry
Main <--> OS
```
@@ -56,8 +58,8 @@ flowchart LR
| Renderer | Screens, interaction, local view composition, theme application | Filesystem, process spawning, raw Electron access, Copilot runtime | Typed preload API and pushed events |
| Preload | Narrow bridge between browser context and Electron IPC | Business logic, persistence, orchestration | `ipcRenderer` / `ipcMain` |
| Main process | Workspace mutation, persistence, git inspection/write operations, run change attribution, commit workflow orchestration, session lifecycle, native window state, global hotkey registration, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes |
| Sidecar | Capability discovery, workflow validation, run execution, provider event normalization, streaming deltas and activity, streamed text assembly | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio |
| External systems | Git data, Copilot account/model access, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar |
| Sidecar | Capability discovery, workflow validation, run execution, provider event normalization, optional OTLP trace export, streaming deltas and activity, streamed text assembly | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio, OTLP to external collectors |
| External systems | Git data, Copilot account/model access, telemetry collectors, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar |
This split is the most important architectural feature in the app. It is what keeps the system understandable as more capabilities are added.
@@ -68,7 +70,7 @@ Aryx runs as a multi-process desktop application:
1. The **renderer** displays the workspace and captures user intent.
2. The **preload bridge** exposes a small, typed API into the browser context.
3. The **main process** validates and mutates application state, persists it, and manages native integrations.
4. The **sidecar** executes Copilot-backed turns and streams structured execution events back.
4. The **sidecar** executes Copilot-backed turns, can export OpenTelemetry traces to an external collector, and streams structured execution events back.
The sidecar is intentionally separate from the Electron main process so that AI runtime concerns stay isolated from UI and persistence concerns.
+9
View File
@@ -92,12 +92,21 @@ bun run test # typecheck + unit tests
bun run sidecar:test # backend tests
bun run build # full build (electron + sidecar)
bun run aspire # start the standalone Aspire Dashboard in Docker
bun run dev:otel # start Aspire + Aryx dev with OTLP export enabled
bun run package # package for current platform → release/
bun run installer # create installable artifact
bun run publish-release # publish to GitHub Releases
bun run release # bump patch version, commit, tag, and push
bun run release minor # bump minor version instead (use major for breaking releases)
```
For local observability, `bun run dev:otel` starts the Aspire Dashboard UI at `http://localhost:18888`
and configures the sidecar to export OpenTelemetry traces to `http://localhost:4317` over OTLP/gRPC.
This dev workflow requires Docker.
Tagged releases use GitHub Actions to build and publish Windows (NSIS), macOS (DMG, signed + notarized), and Linux (AppImage) artifacts. The app uses `electron-updater` for in-app updates.
The release helper expects a clean git worktree, an upstream branch configured for the current branch, and permission to push commits and tags.
## Trademarks
+4 -1
View File
@@ -1,16 +1,19 @@
{
"name": "aryx",
"version": "0.0.25",
"version": "0.0.29",
"description": "Orchestrator for Copilot-powered agent workflows across multiple projects.",
"private": true,
"main": "dist-electron/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"dev:otel": "bun run scripts/dev-with-otel.ts",
"aspire": "bun run scripts/launch-aspire-dashboard.ts",
"build:electron": "electron-vite build",
"build": "bun run build:electron && bun run sidecar:build",
"package": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --dir --publish never",
"installer": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --publish never",
"publish-release": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --publish always",
"release": "bun run scripts/release.ts",
"preview": "electron-vite preview",
"lsp:typescript": "typescript-language-server --stdio",
"typecheck": "tsc --noEmit -p tsconfig.json",
+233
View File
@@ -0,0 +1,233 @@
import { spawn } from 'node:child_process';
import net from 'node:net';
import { setTimeout as delay } from 'node:timers/promises';
export const aspireDashboardContainerName = 'aryx-aspire-dashboard';
export const aspireDashboardImage = 'mcr.microsoft.com/dotnet/aspire-dashboard:latest';
export const aspireDashboardUrls = {
dashboard: 'http://localhost:18888',
otlpGrpc: 'http://localhost:4317',
otlpHttp: 'http://localhost:4318',
} as const;
type ContainerState = 'missing' | 'running' | 'stopped';
export interface AspireDashboardHandle {
readonly dashboardUrl: string;
readonly otlpGrpcEndpoint: string;
readonly otlpHttpEndpoint: string;
readonly startedByScript: boolean;
}
export function createAspireDashboardRunArgs(
containerName = aspireDashboardContainerName,
image = aspireDashboardImage,
): string[] {
return [
'run',
'--rm',
'-d',
'--name',
containerName,
'-p',
'18888:18888',
'-p',
'4317:18889',
'-p',
'4318:18890',
'-e',
'ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true',
image,
];
}
export function createOpenTelemetryEnvironment(
baseEnvironment: NodeJS.ProcessEnv,
endpoint: string = aspireDashboardUrls.otlpGrpc,
): NodeJS.ProcessEnv {
return {
...baseEnvironment,
OTEL_EXPORTER_OTLP_ENDPOINT: endpoint,
OTEL_EXPORTER_OTLP_PROTOCOL: 'grpc',
};
}
export async function startAspireDashboard(): Promise<AspireDashboardHandle> {
await ensureDockerAvailable();
let startedByScript = false;
const containerState = await getContainerState(aspireDashboardContainerName);
if (containerState !== 'running') {
if (containerState === 'stopped') {
await runCommand('docker', ['rm', '-f', aspireDashboardContainerName], 'pipe');
}
await runCommand('docker', createAspireDashboardRunArgs(), 'pipe');
startedByScript = true;
}
await waitForTcpPort(18888);
await waitForTcpPort(4317);
return {
dashboardUrl: aspireDashboardUrls.dashboard,
otlpGrpcEndpoint: aspireDashboardUrls.otlpGrpc,
otlpHttpEndpoint: aspireDashboardUrls.otlpHttp,
startedByScript,
};
}
export async function stopAspireDashboard(): Promise<void> {
const containerState = await getContainerState(aspireDashboardContainerName);
if (containerState === 'missing') {
return;
}
await runCommand('docker', ['rm', '-f', aspireDashboardContainerName], 'pipe');
}
async function ensureDockerAvailable(): Promise<void> {
const cliVersion = await runCommandCapture('docker', ['--version']);
if (cliVersion.exitCode !== 0) {
throw new Error(
'Docker is required to run the standalone Aspire Dashboard. Install Docker Desktop and ensure `docker` is available on PATH.',
);
}
if (await isDockerDaemonAvailable()) {
return;
}
if (await hasDockerDesktopCli()) {
console.log('Docker Desktop is not running. Starting it now...');
await runCommand('docker', ['desktop', 'start', '--detach'], 'pipe');
await waitForDockerDaemon();
return;
}
throw new Error(
'Docker is installed but the daemon is not running. Start Docker Desktop and rerun the Aspire command.',
);
}
async function getContainerState(containerName: string): Promise<ContainerState> {
const result = await runCommandCapture('docker', [
'container',
'inspect',
'--format',
'{{.State.Status}}',
containerName,
]);
if (result.exitCode !== 0) {
return 'missing';
}
return result.stdout.trim() === 'running' ? 'running' : 'stopped';
}
async function waitForTcpPort(port: number, timeoutMs = 30_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await canConnect(port)) {
return;
}
await delay(250);
}
throw new Error(`Timed out waiting for localhost:${port} to accept connections.`);
}
async function isDockerDaemonAvailable(): Promise<boolean> {
const result = await runCommandCapture('docker', ['info', '--format', '{{.ServerVersion}}']);
return result.exitCode === 0;
}
async function hasDockerDesktopCli(): Promise<boolean> {
const result = await runCommandCapture('docker', ['desktop', 'version']);
return result.exitCode === 0;
}
async function waitForDockerDaemon(timeoutMs = 120_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await isDockerDaemonAvailable()) {
return;
}
await delay(1_000);
}
throw new Error('Timed out waiting for Docker Desktop to start and accept API connections.');
}
async function canConnect(port: number): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const socket = net.createConnection({ host: '127.0.0.1', port });
const finalize = (connected: boolean) => {
socket.removeAllListeners();
socket.destroy();
resolve(connected);
};
socket.once('connect', () => finalize(true));
socket.once('error', () => finalize(false));
});
}
async function runCommand(command: string, args: string[], stdio: 'inherit' | 'pipe'): Promise<void> {
const result = await spawnProcess(command, args, stdio);
if (result.exitCode === 0) {
return;
}
if (result.stderr.trim().length > 0) {
throw new Error(result.stderr.trim());
}
throw new Error(`${command} exited with code ${result.exitCode}.`);
}
async function runCommandCapture(command: string, args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
return spawnProcess(command, args, 'pipe');
}
async function spawnProcess(
command: string,
args: string[],
stdio: 'inherit' | 'pipe',
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: stdio === 'inherit' ? 'inherit' : ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stdout = '';
let stderr = '';
if (stdio === 'pipe') {
child.stdout?.on('data', (chunk: Buffer | string) => {
stdout += chunk.toString();
});
child.stderr?.on('data', (chunk: Buffer | string) => {
stderr += chunk.toString();
});
}
child.on('error', reject);
child.on('exit', (code, signal) => {
if (signal) {
reject(new Error(`${command} exited because of signal ${signal}.`));
return;
}
resolve({
exitCode: code ?? 1,
stdout,
stderr,
});
});
});
}
+57
View File
@@ -0,0 +1,57 @@
import { spawn } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
createOpenTelemetryEnvironment,
startAspireDashboard,
stopAspireDashboard,
} from './aspireDashboard';
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const repositoryRoot = resolve(scriptDirectory, '..');
const signalExitCodes: Partial<Record<NodeJS.Signals, number>> = {
SIGINT: 130,
SIGTERM: 143,
};
const dashboard = await startAspireDashboard();
console.log(`Aspire Dashboard: ${dashboard.dashboardUrl}`);
console.log(`Aryx sidecar OTLP endpoint: ${dashboard.otlpGrpcEndpoint}`);
const child = spawn(process.execPath, ['run', 'dev'], {
cwd: repositoryRoot,
env: createOpenTelemetryEnvironment(process.env, dashboard.otlpGrpcEndpoint),
stdio: 'inherit',
windowsHide: true,
});
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
if (child.exitCode === null && child.signalCode === null) {
child.kill(signal);
}
});
}
let exitResult: { code: number | null; signal: NodeJS.Signals | null };
try {
exitResult = await new Promise((resolve, reject) => {
child.on('error', reject);
child.on('exit', (code, signal) => {
resolve({ code, signal });
});
});
} finally {
if (dashboard.startedByScript) {
await stopAspireDashboard();
}
}
if (exitResult.signal) {
process.exitCode = signalExitCodes[exitResult.signal] ?? 1;
} else {
process.exitCode = exitResult.code ?? 1;
}
+9
View File
@@ -0,0 +1,9 @@
import { startAspireDashboard } from './aspireDashboard';
const dashboard = await startAspireDashboard();
console.log(
`${dashboard.startedByScript ? 'Started' : 'Reusing'} Aspire Dashboard at ${dashboard.dashboardUrl}`,
);
console.log(`OTLP/gRPC receiver: ${dashboard.otlpGrpcEndpoint}`);
console.log(`OTLP/HTTP receiver: ${dashboard.otlpHttpEndpoint}`);
+313
View File
@@ -0,0 +1,313 @@
import { spawn } from 'node:child_process';
import { readFile, writeFile } from 'node:fs/promises';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const repositoryRoot = resolve(scriptDirectory, '..');
const packageJsonPath = resolve(repositoryRoot, 'package.json');
export type ReleaseBump = 'patch' | 'minor' | 'major';
interface PackageJsonShape {
version?: unknown;
[key: string]: unknown;
}
export interface GitCommandResult {
exitCode: number;
stdout: string;
stderr: string;
}
export type GitRunner = (args: string[]) => Promise<GitCommandResult>;
export interface ReleaseDependencies {
readText(path: string): Promise<string>;
writeText(path: string, content: string): Promise<void>;
runGit: GitRunner;
log(message: string): void;
}
export interface ReleaseWorkflowOptions {
repositoryRoot: string;
packageJsonPath: string;
bumpArg?: string;
}
export interface ReleaseResult {
bump: ReleaseBump;
currentVersion: string;
nextVersion: string;
tagName: string;
localBranch: string;
remote: string;
upstreamBranch: string;
}
interface UpstreamTarget {
remote: string;
branch: string;
}
function createGitRunner(cwd: string): GitRunner {
return async (args) =>
new Promise((resolvePromise, rejectPromise) => {
const child = spawn('git', args, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (chunk: Buffer | string) => {
stdout += chunk.toString();
});
child.stderr?.on('data', (chunk: Buffer | string) => {
stderr += chunk.toString();
});
child.on('error', rejectPromise);
child.on('close', (code, signal) => {
if (signal) {
rejectPromise(new Error(`git ${args.join(' ')} exited because of signal ${signal}.`));
return;
}
resolvePromise({
exitCode: code ?? 1,
stdout,
stderr,
});
});
});
}
function createReleaseDependencies(cwd: string): ReleaseDependencies {
return {
readText: (path) => readFile(path, 'utf8'),
writeText: (path, content) => writeFile(path, content, 'utf8'),
runGit: createGitRunner(cwd),
log: console.log,
};
}
function parsePackageJson(packageJsonText: string): PackageJsonShape {
try {
return JSON.parse(packageJsonText) as PackageJsonShape;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Could not parse package.json: ${message}`);
}
}
export function parseReleaseBump(value?: string): ReleaseBump {
if (value === undefined) {
return 'patch';
}
if (value === 'patch' || value === 'minor' || value === 'major') {
return value;
}
throw new Error(`Unsupported release bump "${value}". Use patch, minor, or major.`);
}
export function readPackageVersion(packageJsonText: string): string {
const packageJson = parsePackageJson(packageJsonText);
if (typeof packageJson.version !== 'string') {
throw new Error('package.json is missing a string version field.');
}
return packageJson.version;
}
function parseSemver(version: string): [number, number, number] {
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version);
if (!match) {
throw new Error(`Unsupported version "${version}". Expected a simple x.y.z semantic version.`);
}
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
export function incrementVersion(version: string, bump: ReleaseBump): string {
const [major, minor, patch] = parseSemver(version);
switch (bump) {
case 'major':
return `${major + 1}.0.0`;
case 'minor':
return `${major}.${minor + 1}.0`;
case 'patch':
return `${major}.${minor}.${patch + 1}`;
}
}
function detectNewline(packageJsonText: string): '\r\n' | '\n' {
return packageJsonText.includes('\r\n') ? '\r\n' : '\n';
}
export function updatePackageJsonVersion(packageJsonText: string, nextVersion: string): string {
const newline = detectNewline(packageJsonText);
const packageJson = parsePackageJson(packageJsonText);
if (typeof packageJson.version !== 'string') {
throw new Error('package.json is missing a string version field.');
}
packageJson.version = nextVersion;
return `${JSON.stringify(packageJson, null, 2).replace(/\n/g, newline)}${newline}`;
}
function formatGitFailure(args: string[], result: GitCommandResult): string {
const detail = result.stderr.trim() || result.stdout.trim();
if (detail.length > 0) {
return `git ${args.join(' ')} failed: ${detail}`;
}
return `git ${args.join(' ')} failed with exit code ${result.exitCode}.`;
}
async function runGitOrThrow(runGit: GitRunner, args: string[], context: string): Promise<GitCommandResult> {
const result = await runGit(args);
if (result.exitCode !== 0) {
throw new Error(`${context} ${formatGitFailure(args, result)}`);
}
return result;
}
async function ensureCleanWorktree(runGit: GitRunner): Promise<void> {
const result = await runGitOrThrow(runGit, ['status', '--porcelain'], 'Could not inspect the current git worktree.');
if (result.stdout.trim().length > 0) {
throw new Error('Release requires a clean git worktree. Commit, stash, or discard changes first.');
}
}
async function resolveLocalBranch(runGit: GitRunner): Promise<string> {
const result = await runGit(['symbolic-ref', '--quiet', '--short', 'HEAD']);
if (result.exitCode !== 0) {
throw new Error('Release requires a checked-out branch. Detached HEAD is not supported.');
}
const branch = result.stdout.trim();
if (branch.length === 0) {
throw new Error('Release could not determine the current branch name.');
}
return branch;
}
function normalizeUpstreamBranch(mergeRef: string): string {
const prefix = 'refs/heads/';
if (!mergeRef.startsWith(prefix)) {
throw new Error(`Release expected an upstream branch ref, received "${mergeRef}".`);
}
return mergeRef.slice(prefix.length);
}
async function resolveUpstreamTarget(runGit: GitRunner, localBranch: string): Promise<UpstreamTarget> {
const remoteResult = await runGit(['config', '--get', `branch.${localBranch}.remote`]);
const mergeResult = await runGit(['config', '--get', `branch.${localBranch}.merge`]);
if (remoteResult.exitCode !== 0 || mergeResult.exitCode !== 0) {
throw new Error(`Release requires an upstream branch for ${localBranch}. Configure tracking before running the release helper.`);
}
const remote = remoteResult.stdout.trim();
const mergeRef = mergeResult.stdout.trim();
if (remote.length === 0 || mergeRef.length === 0) {
throw new Error(`Release requires an upstream branch for ${localBranch}. Configure tracking before running the release helper.`);
}
return {
remote,
branch: normalizeUpstreamBranch(mergeRef),
};
}
async function ensureTagDoesNotExist(runGit: GitRunner, tagName: string): Promise<void> {
const result = await runGitOrThrow(runGit, ['tag', '--list', tagName], 'Could not inspect existing git tags.');
if (result.stdout.trim().length > 0) {
throw new Error(`Tag ${tagName} already exists. Choose a different release version.`);
}
}
export async function runReleaseWorkflow(
options: ReleaseWorkflowOptions,
dependencies: ReleaseDependencies,
): Promise<ReleaseResult> {
const bump = parseReleaseBump(options.bumpArg);
const packageJsonText = await dependencies.readText(options.packageJsonPath);
const currentVersion = readPackageVersion(packageJsonText);
const nextVersion = incrementVersion(currentVersion, bump);
const tagName = `v${nextVersion}`;
const packageJsonFile = relative(options.repositoryRoot, options.packageJsonPath);
await ensureCleanWorktree(dependencies.runGit);
const localBranch = await resolveLocalBranch(dependencies.runGit);
const upstreamTarget = await resolveUpstreamTarget(dependencies.runGit, localBranch);
await ensureTagDoesNotExist(dependencies.runGit, tagName);
const updatedPackageJson = updatePackageJsonVersion(packageJsonText, nextVersion);
const commitMessage = `chore: release ${tagName}`;
const tagMessage = `Release ${tagName}`;
await dependencies.writeText(options.packageJsonPath, updatedPackageJson);
await runGitOrThrow(dependencies.runGit, ['add', packageJsonFile], `Could not stage ${packageJsonFile}.`);
await runGitOrThrow(dependencies.runGit, ['commit', '-m', commitMessage], 'Could not create the release commit.');
await runGitOrThrow(dependencies.runGit, ['tag', '-a', tagName, '-m', tagMessage], 'Could not create the annotated release tag.');
await runGitOrThrow(
dependencies.runGit,
['push', '--follow-tags', upstreamTarget.remote, `HEAD:${upstreamTarget.branch}`],
'Could not push the release commit and tag.',
);
dependencies.log(`Released ${tagName} from ${localBranch} to ${upstreamTarget.remote}/${upstreamTarget.branch}.`);
return {
bump,
currentVersion,
nextVersion,
tagName,
localBranch,
remote: upstreamTarget.remote,
upstreamBranch: upstreamTarget.branch,
};
}
export async function main(argv: string[]): Promise<void> {
await runReleaseWorkflow(
{
repositoryRoot,
packageJsonPath,
bumpArg: argv[0],
},
createReleaseDependencies(repositoryRoot),
);
}
if (import.meta.main) {
await main(process.argv.slice(2)).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(message);
process.exitCode = 1;
});
}
@@ -10,9 +10,11 @@
<ItemGroup>
<PackageReference Include="GitHub.Copilot.SDK" Version="0.2.1" />
<PackageReference Include="Microsoft.Agents.AI" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.GitHub.Copilot" Version="1.0.0-preview.260402.1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI" Version="1.1.0" />
<PackageReference Include="Microsoft.Agents.AI.GitHub.Copilot" Version="1.1.0-preview.260410.1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.1.0" />
<PackageReference Include="OpenTelemetry" Version="1.13.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
</ItemGroup>
</Project>
+3
View File
@@ -1,4 +1,5 @@
using Aryx.AgentHost.Services;
using OpenTelemetry.Trace;
if (!args.Contains("--stdio", StringComparer.Ordinal))
{
@@ -6,5 +7,7 @@ if (!args.Contains("--stdio", StringComparer.Ordinal))
return;
}
using TracerProvider? tracerProvider = OpenTelemetrySetup.CreateTracerProviderFromEnvironment();
SidecarProtocolHost host = new();
await host.RunAsync(Console.In, Console.Out, CancellationToken.None);
@@ -0,0 +1,137 @@
using System.Collections;
using OpenTelemetry;
using OpenTelemetry.Exporter;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace Aryx.AgentHost.Services;
internal static class OpenTelemetrySetup
{
private const string ServiceName = "Aryx.AgentHost";
private const string EndpointEnvironmentVariableName = "OTEL_EXPORTER_OTLP_ENDPOINT";
private const string ProtocolEnvironmentVariableName = "OTEL_EXPORTER_OTLP_PROTOCOL";
private static readonly string[] ActivitySourceNames =
[
"Experimental.Microsoft.Agents.AI",
"Microsoft.Agents.AI.Workflows",
];
public static TracerProvider? CreateTracerProviderFromEnvironment(TextWriter? diagnosticsWriter = null)
{
return CreateTracerProvider(ReadEnvironmentVariables(), diagnosticsWriter ?? Console.Error);
}
internal static TracerProvider? CreateTracerProvider(
IEnumerable<KeyValuePair<string, string?>> environmentVariables,
TextWriter diagnosticsWriter)
{
ArgumentNullException.ThrowIfNull(environmentVariables);
ArgumentNullException.ThrowIfNull(diagnosticsWriter);
OpenTelemetryTracingConfiguration? configuration = ResolveTracingConfiguration(environmentVariables);
if (configuration is null)
{
return null;
}
diagnosticsWriter.WriteLine(
$"Aryx.AgentHost OpenTelemetry tracing enabled ({configuration.ProtocolLabel}) -> {configuration.Endpoint}.");
return Sdk.CreateTracerProviderBuilder()
.SetResourceBuilder(
ResourceBuilder.CreateDefault()
.AddService(ServiceName, serviceVersion: ResolveServiceVersion()))
.AddSource(configuration.ActivitySourceNames.ToArray())
.AddOtlpExporter(options =>
{
options.Endpoint = configuration.Endpoint;
options.Protocol = configuration.Protocol;
})
.Build();
}
internal static OpenTelemetryTracingConfiguration? ResolveTracingConfiguration(
IEnumerable<KeyValuePair<string, string?>> environmentVariables)
{
ArgumentNullException.ThrowIfNull(environmentVariables);
string? endpointValue = ReadSetting(environmentVariables, EndpointEnvironmentVariableName);
if (string.IsNullOrWhiteSpace(endpointValue))
{
return null;
}
if (!Uri.TryCreate(endpointValue, UriKind.Absolute, out Uri? endpoint)
|| (endpoint.Scheme != Uri.UriSchemeHttp && endpoint.Scheme != Uri.UriSchemeHttps))
{
throw new InvalidOperationException(
$"{EndpointEnvironmentVariableName} must be an absolute http or https URL. Received '{endpointValue}'.");
}
OtlpExportProtocol protocol = ResolveProtocol(ReadSetting(environmentVariables, ProtocolEnvironmentVariableName));
return new OpenTelemetryTracingConfiguration(endpoint, protocol, ActivitySourceNames);
}
private static string? ResolveServiceVersion()
{
return typeof(OpenTelemetrySetup).Assembly.GetName().Version?.ToString();
}
private static IEnumerable<KeyValuePair<string, string?>> ReadEnvironmentVariables()
{
return Environment.GetEnvironmentVariables()
.Cast<DictionaryEntry>()
.Select(entry => new KeyValuePair<string, string?>(
entry.Key?.ToString() ?? string.Empty,
entry.Value?.ToString()));
}
private static string? ReadSetting(
IEnumerable<KeyValuePair<string, string?>> environmentVariables,
string name)
{
foreach (KeyValuePair<string, string?> entry in environmentVariables)
{
if (!string.Equals(entry.Key, name, StringComparison.OrdinalIgnoreCase))
{
continue;
}
return string.IsNullOrWhiteSpace(entry.Value)
? null
: entry.Value.Trim();
}
return null;
}
private static OtlpExportProtocol ResolveProtocol(string? protocolValue)
{
if (string.IsNullOrWhiteSpace(protocolValue))
{
return OtlpExportProtocol.Grpc;
}
return protocolValue.Trim().ToLowerInvariant() switch
{
"grpc" => OtlpExportProtocol.Grpc,
"http/protobuf" => OtlpExportProtocol.HttpProtobuf,
_ => throw new InvalidOperationException(
$"{ProtocolEnvironmentVariableName} must be 'grpc' or 'http/protobuf'. Received '{protocolValue}'."),
};
}
}
internal sealed record OpenTelemetryTracingConfiguration(
Uri Endpoint,
OtlpExportProtocol Protocol,
IReadOnlyList<string> ActivitySourceNames)
{
public string ProtocolLabel => Protocol switch
{
OtlpExportProtocol.HttpProtobuf => "http/protobuf",
_ => "grpc",
};
}
@@ -87,8 +87,13 @@ internal sealed class CopilotAgentBundle : ProviderAgentBundle
name: definition.GetAgentName(),
description: NormalizeOptionalString(definition.Config.Description));
agents.Add(agent);
AIAgent instrumentedAgent = new AIAgentBuilder(agent).UseOpenTelemetry().Build();
agents.Add(instrumentedAgent);
disposables.Add(agent);
if (instrumentedAgent is IDisposable instrumentedDisposable)
{
disposables.Add(new SyncDisposableAdapter(instrumentedDisposable));
}
}
// The bundle owns the shared client — disposed after all agents.
@@ -328,4 +333,17 @@ internal sealed class CopilotAgentBundle : ProviderAgentBundle
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
/// <summary>
/// Adapts a synchronous <see cref="IDisposable"/> to <see cref="IAsyncDisposable"/>
/// so it can be tracked in the async disposal pipeline.
/// </summary>
private sealed class SyncDisposableAdapter(IDisposable inner) : IAsyncDisposable
{
public ValueTask DisposeAsync()
{
inner.Dispose();
return ValueTask.CompletedTask;
}
}
}
@@ -1,5 +1,3 @@
using System.Text.RegularExpressions;
namespace Aryx.AgentHost.Services;
internal static partial class StreamingTextMerger
@@ -7,7 +5,6 @@ internal static partial class StreamingTextMerger
private const double SnapshotReplacementMinLengthRatio = 0.6;
private const int SnapshotReplacementMinTokenCount = 3;
private const double SnapshotReplacementSharedTokenRatio = 0.5;
private const string CharactersThatDoNotNeedLeadingSpace = "([{/\"'`";
public static string Merge(string current, string incoming)
{
@@ -32,51 +29,7 @@ internal static partial class StreamingTextMerger
return incoming;
}
return current + ResolveBoundarySeparator(current, incoming) + incoming;
}
private static bool TryMergeSnapshotVariants(string current, string incoming, out string merged)
{
if (incoming.StartsWith(current, StringComparison.Ordinal)
|| incoming.Contains(current, StringComparison.Ordinal))
{
merged = incoming;
return true;
}
if (current.Contains(incoming, StringComparison.Ordinal))
{
merged = current;
return true;
}
merged = string.Empty;
return false;
}
private static bool TryMergeByOverlap(string current, string incoming, out string merged)
{
int overlapLength = ComputeSuffixPrefixOverlap(current, incoming);
if (overlapLength == 0)
{
merged = string.Empty;
return false;
}
merged = current + incoming[overlapLength..];
return true;
}
private static string ResolveBoundarySeparator(string current, string incoming)
{
if (ShouldInsertNewlineBoundary(current, incoming))
{
return "\n";
}
return ShouldInsertSpaceBoundary(current, incoming)
? " "
: string.Empty;
return current + incoming;
}
private static int ComputeSuffixPrefixOverlap(string current, string incoming)
@@ -125,54 +78,6 @@ internal static partial class StreamingTextMerger
&& incomingTokens.Count >= SnapshotReplacementMinTokenCount;
}
private static bool ShouldInsertNewlineBoundary(string current, string incoming)
{
return !current.EndsWith('\n')
&& MarkdownBlockPrefixRegex().IsMatch(incoming.TrimStart());
}
private static bool ShouldInsertSpaceBoundary(string current, string incoming)
{
char lastCharacter = current[^1];
char firstCharacter = incoming[0];
if (HasExistingBoundary(lastCharacter, firstCharacter)
|| CharactersThatDoNotNeedLeadingSpace.Contains(lastCharacter))
{
return false;
}
if (ClosingPunctuationRegex().IsMatch(incoming))
{
return false;
}
return StartsLikeASeparatedInlineFragment(firstCharacter, incoming)
|| LooksLikeWordBoundary(current, incoming);
}
private static bool HasExistingBoundary(char lastCharacter, char firstCharacter)
{
return char.IsWhiteSpace(lastCharacter) || char.IsWhiteSpace(firstCharacter);
}
private static bool StartsLikeASeparatedInlineFragment(char firstCharacter, string incoming)
{
return MarkdownInlinePrefixRegex().IsMatch(incoming)
|| char.IsUpper(firstCharacter)
|| char.IsDigit(firstCharacter);
}
private static bool LooksLikeWordBoundary(string current, string incoming)
{
string[] currentTokens = Tokenize(current).ToArray();
string[] incomingTokens = Tokenize(incoming).ToArray();
string firstIncomingToken = incomingTokens.FirstOrDefault() ?? string.Empty;
return currentTokens.Length >= 2
&& incomingTokens.Length >= 2
&& firstIncomingToken.Length >= 2;
}
private static IEnumerable<string> Tokenize(string value)
{
return TokenRegex()
@@ -181,15 +86,38 @@ internal static partial class StreamingTextMerger
.Where(token => token.Length > 0);
}
[GeneratedRegex("[a-z0-9]+", RegexOptions.IgnoreCase)]
private static partial Regex TokenRegex();
private static bool TryMergeSnapshotVariants(string current, string incoming, out string merged)
{
if (incoming.StartsWith(current, StringComparison.Ordinal)
|| incoming.Contains(current, StringComparison.Ordinal))
{
merged = incoming;
return true;
}
[GeneratedRegex(@"^[.,!?;:%)\]}]")]
private static partial Regex ClosingPunctuationRegex();
if (current.Contains(incoming, StringComparison.Ordinal))
{
merged = current;
return true;
}
[GeneratedRegex(@"^[*_`~\[]")]
private static partial Regex MarkdownInlinePrefixRegex();
merged = string.Empty;
return false;
}
[GeneratedRegex(@"^(?:#{1,6}\s|[-*+]\s|\d+\.\s|>\s|```)", RegexOptions.Singleline)]
private static partial Regex MarkdownBlockPrefixRegex();
private static bool TryMergeByOverlap(string current, string incoming, out string merged)
{
int overlapLength = ComputeSuffixPrefixOverlap(current, incoming);
if (overlapLength == 0)
{
merged = string.Empty;
return false;
}
merged = current + incoming[overlapLength..];
return true;
}
[System.Text.RegularExpressions.GeneratedRegex("[a-z0-9]+", System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
private static partial System.Text.RegularExpressions.Regex TokenRegex();
}
@@ -112,7 +112,7 @@ internal sealed class WorkflowRunner
builder = builder.WithName(workflowDefinition.Name);
}
return builder.WithOutputFrom(routes[endNode.Id].Exit).Build();
return builder.WithOutputFrom(routes[endNode.Id].Exit).WithOpenTelemetry().Build();
}
private WorkflowNodeRoute CreateNodeRoute(
@@ -0,0 +1,87 @@
using Aryx.AgentHost.Services;
using OpenTelemetry.Exporter;
using OpenTelemetry.Trace;
namespace Aryx.AgentHost.Tests;
public sealed class OpenTelemetrySetupTests
{
[Fact]
public void ResolveTracingConfiguration_ReturnsNullWhenEndpointMissing()
{
OpenTelemetryTracingConfiguration? configuration = OpenTelemetrySetup.ResolveTracingConfiguration([]);
Assert.Null(configuration);
}
[Fact]
public void ResolveTracingConfiguration_UsesGrpcByDefault()
{
OpenTelemetryTracingConfiguration? configuration = OpenTelemetrySetup.ResolveTracingConfiguration(
[
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"),
]);
Assert.NotNull(configuration);
Assert.Equal(new Uri("http://localhost:4317"), configuration.Endpoint);
Assert.Equal(OtlpExportProtocol.Grpc, configuration.Protocol);
Assert.Collection(
configuration.ActivitySourceNames,
source => Assert.Equal("Experimental.Microsoft.Agents.AI", source),
source => Assert.Equal("Microsoft.Agents.AI.Workflows", source));
}
[Fact]
public void ResolveTracingConfiguration_UsesHttpProtobufWhenRequested()
{
OpenTelemetryTracingConfiguration? configuration = OpenTelemetrySetup.ResolveTracingConfiguration(
[
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf"),
]);
Assert.NotNull(configuration);
Assert.Equal(new Uri("http://localhost:4318"), configuration.Endpoint);
Assert.Equal(OtlpExportProtocol.HttpProtobuf, configuration.Protocol);
}
[Fact]
public void ResolveTracingConfiguration_ThrowsWhenEndpointInvalid()
{
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() =>
OpenTelemetrySetup.ResolveTracingConfiguration(
[
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4317"),
]));
Assert.Contains("OTEL_EXPORTER_OTLP_ENDPOINT", error.Message, StringComparison.Ordinal);
}
[Fact]
public void ResolveTracingConfiguration_ThrowsWhenProtocolUnsupported()
{
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() =>
OpenTelemetrySetup.ResolveTracingConfiguration(
[
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"),
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_PROTOCOL", "http/json"),
]));
Assert.Contains("OTEL_EXPORTER_OTLP_PROTOCOL", error.Message, StringComparison.Ordinal);
}
[Fact]
public void CreateTracerProvider_CreatesProviderAndWritesDiagnosticsWhenConfigured()
{
StringWriter diagnosticsWriter = new();
using TracerProvider? tracerProvider = OpenTelemetrySetup.CreateTracerProvider(
[
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"),
],
diagnosticsWriter);
Assert.NotNull(tracerProvider);
Assert.Contains("Aryx.AgentHost OpenTelemetry tracing enabled", diagnosticsWriter.ToString(), StringComparison.Ordinal);
}
}
@@ -37,22 +37,36 @@ public sealed class StreamingTextMergerTests
Assert.Equal(incoming, StreamingTextMerger.Merge(current, incoming));
}
[Fact]
public void Merge_InsertsWhitespaceWhenSnapshotLikeUpdatesWouldOtherwiseGlueWordsTogether()
[Theory]
[InlineData("requires all wr", "itable fields", "requires all writable fields")]
[InlineData("becomes frag", "ile for clients", "becomes fragile for clients")]
[InlineData("Endpoint (domain) uniqu", "eness across tenants", "Endpoint (domain) uniqueness across tenants")]
[InlineData("The doc says \"wildc", "ards are allowed\"", "The doc says \"wildcards are allowed\"")]
[InlineData("What wildcard syntax supported (*.cont", "oso.com? contoso.* ?)", "What wildcard syntax supported (*.contoso.com? contoso.* ?)")]
[InlineData("How does Pur", "view match traffic", "How does Purview match traffic")]
[InlineData("more M", "DA properties", "more MDA properties")]
[InlineData("does UA", "G normalize them?", "does UAG normalize them?")]
public void Merge_DoesNotInjectSpacesIntoSplitWords(string current, string incoming, string expected)
{
Assert.Equal(
"How about The **Ashen Crown** feels",
StreamingTextMerger.Merge("How about", "The **Ashen Crown** feels"));
Assert.Equal(
"The **Ashen Crown** feels classic and timeless.",
StreamingTextMerger.Merge("The **Ashen Crown** feels", "classic and timeless."));
Assert.Equal(expected, StreamingTextMerger.Merge(current, incoming));
}
[Fact]
public void Merge_InsertsNewlineBeforeStreamedMarkdownBlockMarkers()
public void Merge_PreservesWhitespaceAlreadyPresentInDelta()
{
Assert.Equal(
"How about The **Ashen Crown** feels",
StreamingTextMerger.Merge("How about", " The **Ashen Crown** feels"));
Assert.Equal(
"The **Ashen Crown** feels classic and timeless.",
StreamingTextMerger.Merge("The **Ashen Crown** feels", " classic and timeless."));
}
[Fact]
public void Merge_PreservesNewlineAlreadyPresentInDelta()
{
Assert.Equal(
"If you want, I can also give you\n- darker titles",
StreamingTextMerger.Merge("If you want, I can also give you", "- darker titles"));
StreamingTextMerger.Merge("If you want, I can also give you", "\n- darker titles"));
}
}
+53
View File
@@ -140,6 +140,7 @@ import {
type AppearanceTheme,
type LspProfileDefinition,
type McpServerDefinition,
type OpenTelemetrySettings,
type QuickPromptSettings,
type SessionToolingSelection,
type WorkspaceToolingSettings,
@@ -469,6 +470,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
async loadWorkspace(): Promise<WorkspaceState> {
if (!this.workspace) {
this.workspace = await this.workspaceRepository.load();
this.sidecar.setOpenTelemetrySettings(this.workspace.settings.openTelemetry);
const selectedProjectId = this.workspace.selectedProjectId;
const selectedProject = selectedProjectId
? this.workspace.projects.find((project) => project.id === selectedProjectId)
@@ -830,6 +832,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async setOpenTelemetry(settings: OpenTelemetrySettings): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
workspace.settings.openTelemetry = settings;
this.sidecar.setOpenTelemetrySettings(settings);
return this.persistAndBroadcast(workspace);
}
getQuickPromptSettings(): QuickPromptSettings {
return this.workspace?.settings.quickPrompt ?? createDefaultQuickPromptSettings();
}
@@ -1149,6 +1158,50 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async batchSetSessionsArchived(sessionIds: string[], isArchived: boolean): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const now = nowIso();
for (const sessionId of sessionIds) {
const session = workspace.sessions.find((s) => s.id === sessionId);
if (session) {
session.isArchived = isArchived;
session.updatedAt = now;
}
}
return this.persistAndBroadcast(workspace);
}
async batchDeleteSessions(sessionIds: string[]): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const idsToDelete = new Set(sessionIds);
// Run cleanup sequentially to avoid requestId collisions in sidecar dispatch
for (const session of workspace.sessions) {
if (!idsToDelete.has(session.id)) continue;
const scratchpadDirectory = this.resolveScratchpadSessionDirectory(session);
if (scratchpadDirectory) {
await rm(scratchpadDirectory, { recursive: true, force: true }).catch(() => {
// Best-effort — directory may not exist or be locked
});
}
try {
await this.sidecar.deleteSession(session.id);
} catch {
// Best-effort — don't fail the deletion if SDK cleanup fails
}
}
workspace.sessions = workspace.sessions.filter((s) => !idsToDelete.has(s.id));
if (workspace.selectedSessionId && idsToDelete.has(workspace.selectedSessionId)) {
workspace.selectedSessionId = workspace.sessions[0]?.id;
}
return this.persistAndBroadcast(workspace);
}
async regenerateSessionMessage(sessionId: string, messageId: string): Promise<void> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
+16 -4
View File
@@ -16,6 +16,12 @@ import { createDefaultQuickPromptSettings } from '@shared/domain/tooling';
const { app, BrowserWindow } = electron;
// Enforce single instance — quit immediately if another instance already holds the lock.
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
}
let mainWindow: BrowserWindowType | undefined;
let quickPromptWindow: BrowserWindowType | undefined;
let appService: AryxAppService | undefined;
@@ -63,9 +69,9 @@ async function bootstrap(): Promise<void> {
applyTitleBarTheme(mainWindow, workspace.settings.theme);
systemTray = new SystemTray({
onShowWindow: showAndFocusWindow,
onShowWindow: () => showAndFocusWindow(mainWindow!),
onCreateScratchpad: () => {
showAndFocusWindow();
showAndFocusWindow(mainWindow!);
mainWindow?.webContents.send('tray:create-scratchpad');
},
onQuit: () => app.quit(),
@@ -109,6 +115,12 @@ async function bootstrap(): Promise<void> {
autoUpdateService.start();
}
app.on('second-instance', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
showAndFocusWindow(mainWindow);
}
});
app.whenReady().then(bootstrap);
app.on('window-all-closed', () => {
@@ -127,8 +139,8 @@ app.on('window-all-closed', () => {
app.on('activate', async () => {
if (BrowserWindow.getAllWindows().length === 0) {
await bootstrap();
} else {
showAndFocusWindow();
} else if (mainWindow && !mainWindow.isDestroyed()) {
showAndFocusWindow(mainWindow);
}
});
+13 -1
View File
@@ -4,6 +4,8 @@ import type { BrowserWindow } from 'electron';
import { ipcChannels } from '@shared/contracts/channels';
import type {
BranchSessionInput,
BatchDeleteSessionsInput,
BatchSetSessionsArchivedInput,
CancelSessionTurnInput,
CommitProjectGitChangesInput,
CreateSessionInput,
@@ -54,7 +56,7 @@ import type {
QuickPromptSendInput,
} from '@shared/contracts/ipc';
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
import type { AppearanceTheme, QuickPromptSettings } from '@shared/domain/tooling';
import type { AppearanceTheme, OpenTelemetrySettings, QuickPromptSettings } from '@shared/domain/tooling';
import { AryxAppService } from '@main/AryxAppService';
import { AutoUpdateService } from '@main/services/autoUpdater';
@@ -153,6 +155,10 @@ export function registerIpcHandlers(
ipcChannels.setGitAutoRefreshEnabled,
(_event, enabled: boolean) => service.setGitAutoRefreshEnabled(enabled),
);
ipcMain.handle(
ipcChannels.setOpenTelemetry,
(_event, settings: OpenTelemetrySettings) => service.setOpenTelemetry(settings),
);
ipcMain.handle(ipcChannels.checkForUpdates, () => autoUpdateService.checkForUpdates());
ipcMain.handle(ipcChannels.installUpdate, () => {
autoUpdateService.installUpdate();
@@ -224,6 +230,12 @@ export function registerIpcHandlers(
ipcMain.handle(ipcChannels.deleteSession, (_event, input: DeleteSessionInput) =>
service.deleteSession(input.sessionId),
);
ipcMain.handle(ipcChannels.batchSetSessionsArchived, (_event, input: BatchSetSessionsArchivedInput) =>
service.batchSetSessionsArchived(input.sessionIds, input.isArchived),
);
ipcMain.handle(ipcChannels.batchDeleteSessions, (_event, input: BatchDeleteSessionsInput) =>
service.batchDeleteSessions(input.sessionIds),
);
ipcMain.handle(ipcChannels.regenerateSessionMessage, (_event, input: RegenerateSessionMessageInput) =>
service.regenerateSessionMessage(input.sessionId, input.messageId),
);
+3 -5
View File
@@ -3,7 +3,7 @@ import { join } from 'node:path';
import type { WorkspaceState } from '@shared/domain/workspace';
const { app, Menu, Tray, nativeImage, BrowserWindow } = electron;
const { app, Menu, Tray, nativeImage } = electron;
type TrayType = InstanceType<typeof Tray>;
type NativeImageType = ReturnType<typeof nativeImage.createFromPath>;
@@ -123,10 +123,8 @@ export function setupCloseToTray(
/**
* Show and focus the main window, restoring from tray if hidden.
*/
export function showAndFocusWindow(): void {
const windows = BrowserWindow.getAllWindows();
const mainWindow = windows[0];
if (!mainWindow) return;
export function showAndFocusWindow(mainWindow: Electron.BrowserWindow): void {
if (mainWindow.isDestroyed()) return;
// On macOS, show the dock icon again
if (process.platform === 'darwin') {
+10 -1
View File
@@ -1,6 +1,11 @@
import type { OpenTelemetrySettings } from '@shared/domain/tooling';
const blockedEnvironmentPrefixes = ['BUN_', 'COPILOT_', 'ELECTRON_', 'NODE_', 'NPM_'];
export function createSidecarEnvironment(baseEnvironment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
export function createSidecarEnvironment(
baseEnvironment: NodeJS.ProcessEnv,
openTelemetry?: OpenTelemetrySettings,
): NodeJS.ProcessEnv {
const sanitizedEnvironment: NodeJS.ProcessEnv = {};
for (const [name, value] of Object.entries(baseEnvironment)) {
@@ -13,5 +18,9 @@ export function createSidecarEnvironment(baseEnvironment: NodeJS.ProcessEnv): No
sanitizedEnvironment[name] = value;
}
if (openTelemetry?.enabled && openTelemetry.endpoint) {
sanitizedEnvironment.OTEL_EXPORTER_OTLP_ENDPOINT = openTelemetry.endpoint;
}
return sanitizedEnvironment;
}
+7 -1
View File
@@ -21,6 +21,7 @@ import type {
} from '@shared/contracts/sidecar';
import type { ApprovalDecision } from '@shared/domain/approval';
import type { ChatMessageRecord } from '@shared/domain/session';
import type { OpenTelemetrySettings } from '@shared/domain/tooling';
import { createSidecarEnvironment } from '@main/sidecar/sidecarEnvironment';
import {
markRunTurnPendingErrored,
@@ -109,6 +110,11 @@ export class SidecarClient {
private processState?: ManagedSidecarProcess;
private nextProcessId = 0;
private readonly pending = new Map<string, PendingCommand>();
private openTelemetrySettings?: OpenTelemetrySettings;
setOpenTelemetrySettings(settings?: OpenTelemetrySettings): void {
this.openTelemetrySettings = settings;
}
async describeCapabilities(): Promise<SidecarCapabilities> {
const command = await this.dispatch<SidecarCapabilities>({
@@ -240,7 +246,7 @@ export class SidecarClient {
});
const childProcess = spawn(sidecar.command, sidecar.args, {
cwd: sidecar.cwd,
env: createSidecarEnvironment(process.env),
env: createSidecarEnvironment(process.env, this.openTelemetrySettings),
stdio: 'pipe',
windowsHide: true,
});
+3
View File
@@ -36,6 +36,7 @@ const api: ElectronApi = {
setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled),
setMinimizeToTray: (enabled) => ipcRenderer.invoke(ipcChannels.setMinimizeToTray, enabled),
setGitAutoRefreshEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setGitAutoRefreshEnabled, enabled),
setOpenTelemetry: (settings) => ipcRenderer.invoke(ipcChannels.setOpenTelemetry, settings),
getQuickPromptSettings: () => ipcRenderer.invoke(ipcChannels.quickPromptGetSettings),
setQuickPromptSettings: (settings) => ipcRenderer.invoke(ipcChannels.quickPromptSetSettings, settings),
checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates),
@@ -67,6 +68,8 @@ const api: ElectronApi = {
setSessionPinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionPinned, input),
setSessionArchived: (input) => ipcRenderer.invoke(ipcChannels.setSessionArchived, input),
deleteSession: (input) => ipcRenderer.invoke(ipcChannels.deleteSession, input),
batchSetSessionsArchived: (input) => ipcRenderer.invoke(ipcChannels.batchSetSessionsArchived, input),
batchDeleteSessions: (input) => ipcRenderer.invoke(ipcChannels.batchDeleteSessions, input),
regenerateSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.regenerateSessionMessage, input),
editAndResendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.editAndResendSessionMessage, input),
sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input),
+8
View File
@@ -873,6 +873,8 @@ export default function App() {
setQuickPromptSettings(updated);
void api.setQuickPromptSettings(patch);
}}
openTelemetry={workspace.settings.openTelemetry}
onSetOpenTelemetry={(settings) => void api.setOpenTelemetry(settings)}
/>
</Suspense>
) : null;
@@ -940,6 +942,12 @@ export default function App() {
onDeleteSession={(sessionId) => {
void api.deleteSession({ sessionId });
}}
onBatchArchiveSessions={(sessionIds, isArchived) => {
void api.batchSetSessionsArchived({ sessionIds, isArchived });
}}
onBatchDeleteSessions={(sessionIds) => {
void api.batchDeleteSessions({ sessionIds });
}}
onRefreshGitContext={(projectId) => {
void api.refreshProjectGitContext(projectId);
}}
+21
View File
@@ -228,9 +228,30 @@ export function ChatPane({
const activeRun = runsByTrigger.get(lastUserMessageId);
if (activeRun && !consumedRunIds.has(activeRun.id)) {
items.push({ type: 'turn-activity', thinkingMessages: [], run: activeRun, turnStartedAt: activeRun.startedAt });
consumedRunIds.add(activeRun.id);
}
}
// Inject activity panels for any remaining unconsumed runs from
// previous turns (e.g. turns where thinking messages were not
// produced or were lost). Place each panel right after the trigger
// user message so it appears in the correct chronological position.
for (const run of session.runs) {
if (consumedRunIds.has(run.id)) continue;
const triggerIndex = items.findIndex(
(it) => it.type === 'message' && it.message.role === 'user' && it.message.id === run.triggerMessageId,
);
if (triggerIndex === -1) continue;
const panel: DisplayItem = {
type: 'turn-activity',
thinkingMessages: [],
run,
turnStartedAt: run.startedAt,
};
items.splice(triggerIndex + 1, 0, panel);
consumedRunIds.add(run.id);
}
// Tag the last turn-activity panel for each run so only it shows
// run-level metadata (git summary, discard button, etc.).
const lastPanelIndexByRunId = new Map<string, number>();
+97 -23
View File
@@ -1,14 +1,13 @@
import { useEffect, useState, type ReactNode } from 'react';
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranch, Palette, Plus, RefreshCw, Server, Sparkles, TriangleAlert, UserCircle, Wrench } from 'lucide-react';
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranch, Palette, Plus, RefreshCw, Server, Sparkles, TriangleAlert, UserCircle, Wrench, Activity } from 'lucide-react';
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
import { WorkflowEditor } from '@renderer/components/WorkflowEditor';
import { ToggleSwitch } from '@renderer/components/ui';
import { HotkeyRecorder, TextInput, ToggleSwitch } from '@renderer/components/ui';
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
import { WorkspaceAgentEditor } from '@renderer/components/settings/WorkspaceAgentEditor';
import { getElectronApi } from '@renderer/lib/electronApi';
import { isMac } from '@renderer/lib/platform';
import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidecar';
import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling';
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
@@ -19,9 +18,11 @@ import type { WorkflowTemplateCategory, WorkflowTemplateDefinition } from '@shar
import {
normalizeLspProfileDefinition,
normalizeMcpServerDefinition,
DEFAULT_OTEL_ENDPOINT,
type AppearanceTheme,
type LspProfileDefinition,
type McpServerDefinition,
type OpenTelemetrySettings,
type QuickPromptSettings,
type WorkspaceToolingSettings,
} from '@shared/domain/tooling';
@@ -66,9 +67,11 @@ interface SettingsPanelProps {
onCreateWorkflowFromTemplate?: (templateId: string, name?: string) => Promise<void>;
quickPromptSettings?: QuickPromptSettings;
onSetQuickPromptSettings?: (patch: Partial<QuickPromptSettings>) => void;
openTelemetry?: OpenTelemetrySettings;
onSetOpenTelemetry?: (settings: OpenTelemetrySettings) => void;
}
export type SettingsSection = 'appearance' | 'connection' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'quick-prompt' | 'troubleshooting';
export type SettingsSection = 'appearance' | 'connection' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'quick-prompt' | 'telemetry' | 'troubleshooting';
interface NavItem {
id: SettingsSection;
@@ -87,6 +90,7 @@ const navGroups: NavGroup[] = [
items: [
{ id: 'appearance', label: 'Appearance', icon: <Palette className="size-3.5" /> },
{ id: 'quick-prompt', label: 'Quick Prompt', icon: <Sparkles className="size-3.5" /> },
{ id: 'telemetry', label: 'Telemetry', icon: <Activity className="size-3.5" /> },
],
},
{
@@ -156,6 +160,8 @@ export function SettingsPanel({
onCreateWorkflowFromTemplate,
quickPromptSettings,
onSetQuickPromptSettings,
openTelemetry,
onSetOpenTelemetry,
}: SettingsPanelProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowDefinition | null>(null);
@@ -199,6 +205,7 @@ export function SettingsPanel({
}}
workflow={editingWorkflow}
workflows={workflows}
workspaceAgents={workspaceAgents}
/>
</div>
);
@@ -394,6 +401,12 @@ export function SettingsPanel({
onUpdate={onSetQuickPromptSettings}
/>
)}
{activeSection === 'telemetry' && (
<TelemetrySection
openTelemetry={openTelemetry}
onSetOpenTelemetry={onSetOpenTelemetry}
/>
)}
{activeSection === 'troubleshooting' && (
<TroubleshootingSection
onOpenAppDataFolder={onOpenAppDataFolder}
@@ -548,6 +561,67 @@ function AppearanceSection({
);
}
function TelemetrySection({
openTelemetry,
onSetOpenTelemetry,
}: {
openTelemetry?: OpenTelemetrySettings;
onSetOpenTelemetry?: (settings: OpenTelemetrySettings) => void;
}) {
const enabled = openTelemetry?.enabled ?? false;
const endpoint = openTelemetry?.endpoint ?? DEFAULT_OTEL_ENDPOINT;
const handleToggle = () => {
onSetOpenTelemetry?.({ enabled: !enabled, endpoint });
};
const handleEndpointChange = (value: string) => {
onSetOpenTelemetry?.({ enabled, endpoint: value });
};
return (
<div>
<div className="mb-1">
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">OpenTelemetry</h3>
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
Export traces from the sidecar to an OTLP-compatible collector
</p>
</div>
<button
className="mt-4 flex w-full items-center justify-between rounded-lg border border-[var(--color-border)] px-4 py-3 text-left transition hover:bg-[var(--color-surface-3)]/40"
onClick={handleToggle}
type="button"
>
<div>
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
Enable OTLP export
</span>
<p className="text-[12px] text-[var(--color-text-muted)]">
Send traces to the configured OTLP endpoint when a new sidecar session starts
</p>
</div>
<ToggleSwitch enabled={enabled} />
</button>
<div className="mt-5">
<label className="mb-1.5 block text-[12px] font-medium text-[var(--color-text-secondary)]">
OTLP endpoint
</label>
<TextInput
value={endpoint}
onChange={handleEndpointChange}
placeholder={DEFAULT_OTEL_ENDPOINT}
/>
<p className="mt-1.5 text-[11px] text-[var(--color-text-muted)]">
The URL of any OTLP-compatible collector (e.g. Jaeger, Aspire Dashboard, Grafana Alloy).
Changes take effect on the next session.
</p>
</div>
</div>
);
}
function ConnectionSection({
connection,
modelCount,
@@ -1338,13 +1412,6 @@ function QuickPromptSettingsSection({
const resolvedModel = defaultModel ? availableModels.find((m) => m.id === defaultModel) : undefined;
const modelSupportsReasoning = resolvedModel?.supportedReasoningEfforts?.length;
const hotkeyKeys = hotkey
.replace('Super', isMac ? '⌘' : 'Win')
.replace('Alt', isMac ? '⌥' : 'Alt')
.replace('Shift', '⇧')
.split('+')
.map((k) => k.trim());
// Group models by tier for the dropdown
const tierOrder = ['premium', 'standard', 'fast'] as const;
const tierLabels: Record<string, string> = { premium: 'Premium', standard: 'Standard', fast: 'Fast' };
@@ -1371,7 +1438,7 @@ function QuickPromptSettingsSection({
</p>
</div>
{/* Enable / Disable + Keyboard Shortcut — compact row */}
{/* Enable / Disable toggle */}
<div className="mt-5 flex items-center gap-3 rounded-lg border border-[var(--color-border)] px-4 py-3">
<button
className="flex flex-1 items-start gap-0 text-left"
@@ -1380,18 +1447,11 @@ function QuickPromptSettingsSection({
>
<div className="flex-1">
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
Global hotkey
Enable global hotkey
</span>
<div className="mt-1.5 flex items-center gap-1">
{hotkeyKeys.map((key) => (
<kbd
key={key}
className="rounded-[5px] border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2 py-[3px] font-mono text-[11px] font-medium leading-none text-[var(--color-text-secondary)] shadow-sm shadow-black/20"
>
{key}
</kbd>
))}
</div>
<p className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
Summon Quick Prompt from any app
</p>
</div>
</button>
<button onClick={() => onUpdate?.({ enabled: !enabled })} type="button">
@@ -1399,6 +1459,20 @@ function QuickPromptSettingsSection({
</button>
</div>
{/* Hotkey Recorder */}
<div className="mt-4">
<div className="mb-2 flex items-center justify-between">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
Keyboard shortcut
</span>
</div>
<HotkeyRecorder
value={hotkey}
disabled={!enabled}
onChange={(newHotkey) => onUpdate?.({ hotkey: newHotkey })}
/>
</div>
{/* Default Model — compact dropdown selector */}
<div className="mt-6">
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Default Model</h3>
+267 -22
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import appIconUrl from '../../../assets/icons/icon.png';
import { isMac } from '@renderer/lib/platform';
import {
AlertTriangle,
Archive,
ArrowLeftRight,
Check,
ChevronDown,
ChevronRight,
Circle,
@@ -35,6 +36,10 @@ import { querySessions } from '@shared/domain/sessionLibrary';
import type { UpdateStatus } from '@shared/contracts/ipc';
import type { WorkspaceState } from '@shared/domain/workspace';
import { UpdateBanner } from '@renderer/components/ui';
import { useSessionSelection } from '@renderer/hooks/useSessionSelection';
import { BatchActionBar } from '@renderer/components/sidebar/BatchActionBar';
import { BatchDeleteConfirmDialog } from '@renderer/components/sidebar/BatchDeleteConfirmDialog';
import { UndoToast } from '@renderer/components/sidebar/UndoToast';
interface SidebarProps {
workspace: WorkspaceState;
@@ -50,6 +55,8 @@ interface SidebarProps {
onSetSessionPinned: (sessionId: string, isPinned: boolean) => void;
onSetSessionArchived: (sessionId: string, isArchived: boolean) => void;
onDeleteSession: (sessionId: string) => void;
onBatchArchiveSessions: (sessionIds: string[], isArchived: boolean) => void;
onBatchDeleteSessions: (sessionIds: string[]) => void;
onRefreshGitContext: (projectId: string) => void;
updateStatus?: UpdateStatus;
onViewUpdateDetails?: () => void;
@@ -183,6 +190,12 @@ function SessionItem({
onOpenMenu,
onRenameSubmit,
onRenameCancel,
isSelecting,
isSelected,
selectionIndex,
onToggleSelection,
onEnterSelectionMode,
onShiftSelect,
}: {
session: SessionRecord;
workflow?: WorkflowDefinition;
@@ -192,6 +205,12 @@ function SessionItem({
onOpenMenu: (e: React.MouseEvent) => void;
onRenameSubmit: (title: string) => void;
onRenameCancel: () => void;
isSelecting?: boolean;
isSelected?: boolean;
selectionIndex?: number;
onToggleSelection?: () => void;
onEnterSelectionMode?: () => void;
onShiftSelect?: () => void;
}) {
const isRunning = session.status === 'running';
const isError = session.status === 'error';
@@ -201,6 +220,7 @@ function SessionItem({
const visual = modeVisuals[mode];
const ModeIcon = visual.icon;
const agentCount = workflow ? resolveWorkflowAgentNodes(workflow).length : 1;
const isSelectDisabled = isRunning;
const [renameText, setRenameText] = useState(session.title);
const inputRef = useRef<HTMLInputElement>(null);
@@ -232,34 +252,96 @@ function SessionItem({
else onRenameCancel();
}
function handleClick(e: React.MouseEvent) {
if (isRenaming) return;
// Already in selection mode — toggle or range-select
if (isSelecting) {
if (isSelectDisabled) return;
if (e.shiftKey) {
onShiftSelect?.();
} else {
onToggleSelection?.();
}
return;
}
// Not in selection mode — check for modifier key to enter it
const modKey = isMac ? e.metaKey : e.ctrlKey;
if (modKey && !isSelectDisabled) {
onEnterSelectionMode?.();
return;
}
onSelect();
}
function handleKeyDown(e: React.KeyboardEvent) {
if ((e.key === 'Enter' || e.key === ' ') && !isRenaming) {
e.preventDefault();
if (isSelecting) {
if (!isSelectDisabled) onToggleSelection?.();
} else {
onSelect();
}
}
}
return (
<div
className={`session-item-enter group relative flex w-full cursor-pointer items-start gap-2.5 rounded-lg px-2.5 py-2 text-left transition-all duration-200 ${
isActive
isSelecting && isSelected
? 'bg-[var(--color-accent-muted)] ring-1 ring-[var(--color-border-glow)]'
: 'hover:bg-[var(--color-surface-2)]/60'
} ${isRunning ? 'sidebar-running' : ''} ${session.isArchived ? 'opacity-50' : ''}`}
onClick={isRenaming ? undefined : onSelect}
role="button"
: isActive && !isSelecting
? 'bg-[var(--color-accent-muted)] ring-1 ring-[var(--color-border-glow)]'
: 'hover:bg-[var(--color-surface-2)]/60'
} ${isRunning ? 'sidebar-running' : ''} ${session.isArchived ? 'opacity-50' : ''} ${isSelectDisabled ? 'cursor-not-allowed opacity-40' : ''}`}
onClick={handleClick}
role={isSelecting ? 'checkbox' : 'button'}
aria-checked={isSelecting ? isSelected : undefined}
tabIndex={0}
onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && !isRenaming) { e.preventDefault(); onSelect(); } }}
onKeyDown={handleKeyDown}
>
{/* Running/approval left accent bar */}
{isRunning && !hasPendingApproval && (
{isRunning && !hasPendingApproval && !isSelecting && (
<span className="absolute inset-y-1.5 left-0 w-[3px] rounded-full accent-flow" />
)}
{hasPendingApproval && (
{hasPendingApproval && !isSelecting && (
<span className="absolute inset-y-1.5 left-0 w-[3px] rounded-full bg-[var(--color-status-warning)]" />
)}
{/* Selection accent bar */}
{isSelecting && isSelected && (
<span className="absolute inset-y-1.5 left-0 w-[3px] rounded-full bg-[var(--color-accent)]" />
)}
{/* Mode icon */}
<span
className={`mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-md ${
isActive ? 'bg-[var(--color-accent-muted)]' : 'bg-[var(--color-surface-2)]'
}`}
>
<ModeIcon className={`size-3.5 ${isActive ? 'text-[var(--color-accent)]' : visual.color}`} />
</span>
{/* Mode icon or selection checkbox */}
{isSelecting ? (
<span
className="selection-checkbox-enter mt-0.5 flex size-6 shrink-0 items-center justify-center"
style={{ animationDelay: `${(selectionIndex ?? 0) * 30}ms` }}
title={isSelectDisabled ? "Can't select running sessions" : undefined}
>
<span
className={`flex size-4 items-center justify-center rounded border transition-all duration-150 ${
isSelected
? 'checkbox-check border-[var(--color-accent)] bg-[var(--color-accent)]'
: isSelectDisabled
? 'border-[var(--color-text-muted)]/30 bg-transparent'
: 'border-[var(--color-text-muted)]/50 bg-transparent hover:border-[var(--color-accent)]/50'
}`}
>
{isSelected && <Check className="size-3 text-white" strokeWidth={3} />}
</span>
</span>
) : (
<span
className={`mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-md ${
isActive ? 'bg-[var(--color-accent-muted)]' : 'bg-[var(--color-surface-2)]'
}`}
>
<ModeIcon className={`size-3.5 ${isActive ? 'text-[var(--color-accent)]' : visual.color}`} />
</span>
)}
{/* Content */}
<div className="min-w-0 flex-1">
@@ -342,8 +424,8 @@ function SessionItem({
</div>
</div>
{/* Actions button (hidden during rename) */}
{!isRenaming && (
{/* Actions button (hidden during rename and selection mode) */}
{!isRenaming && !isSelecting && (
<button
className="absolute right-1.5 top-1.5 flex size-6 items-center justify-center rounded-md text-[var(--color-text-muted)] opacity-0 transition-all duration-150 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)] group-hover:opacity-100"
onClick={(e) => { e.stopPropagation(); onOpenMenu(e); }}
@@ -372,6 +454,11 @@ function ProjectGroup({
onOpenProjectSettings,
onNewSession,
newSessionLabel,
isSelecting,
isSelected,
onToggleSelection,
onEnterSelectionMode,
onShiftSelect,
}: {
project: ProjectRecord;
sessions: SessionRecord[];
@@ -386,6 +473,11 @@ function ProjectGroup({
onOpenProjectSettings?: (projectId: string) => void;
onNewSession?: () => void;
newSessionLabel?: string;
isSelecting?: boolean;
isSelected?: (sessionId: string) => boolean;
onToggleSelection?: (sessionId: string) => void;
onEnterSelectionMode?: (sessionId: string) => void;
onShiftSelect?: (sessionId: string) => void;
}){
const [expanded, setExpanded] = useState(true);
const isScratchpad = isScratchpadProject(project);
@@ -508,7 +600,7 @@ function ProjectGroup({
{expanded && (
<div className="ml-2 mt-0.5 space-y-0.5 border-l border-[var(--color-border-subtle)] pl-2">
{visibleSessions.length > 0 &&
visibleSessions.map((session) => (
visibleSessions.map((session, index) => (
<SessionItem
isActive={selectedSessionId === session.id}
isRenaming={renamingSessionId === session.id}
@@ -519,6 +611,12 @@ function ProjectGroup({
onRenameCancel={onRenameCancel}
workflow={workflowMap.get(session.workflowId)}
session={session}
isSelecting={isSelecting}
isSelected={isSelected?.(session.id)}
selectionIndex={index}
onToggleSelection={() => onToggleSelection?.(session.id)}
onEnterSelectionMode={() => onEnterSelectionMode?.(session.id)}
onShiftSelect={() => onShiftSelect?.(session.id)}
/>
))}
{onNewSession ? (
@@ -559,6 +657,8 @@ export function Sidebar({
onSetSessionPinned,
onSetSessionArchived,
onDeleteSession,
onBatchArchiveSessions,
onBatchDeleteSessions,
onRefreshGitContext,
updateStatus,
onViewUpdateDetails,
@@ -600,6 +700,7 @@ export function Sidebar({
const scrollRef = useRef<HTMLDivElement>(null);
function handleOpenMenu(sessionId: string, e: React.MouseEvent) {
if (selection.isSelecting) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setMenuState({
sessionId,
@@ -621,6 +722,102 @@ export function Sidebar({
? workspace.sessions.find((s) => s.id === menuState.sessionId)
: undefined;
/* ── Multi-select state ────────────────────────────────────── */
const selection = useSessionSelection();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [undoToast, setUndoToast] = useState<{ message: string; sessionIds: string[]; isArchived: boolean } | null>(null);
// All selectable (non-running) session IDs across the visible list
const allSelectableIds = useMemo(() => {
const sessions = workspace.sessions.filter((s) => !s.isArchived && s.status !== 'running');
return sessions.map((s) => s.id);
}, [workspace.sessions]);
// All visible session IDs (for range select and "select all")
const allVisibleIds = useMemo(() => {
if (isQueryActive) return queryResults.map((s) => s.id);
return workspace.sessions.filter((s) => !s.isArchived).map((s) => s.id);
}, [isQueryActive, queryResults, workspace.sessions]);
const allSelectedArchived = useMemo(() => {
if (selection.selectedIds.size === 0) return false;
return [...selection.selectedIds].every((id) => {
const session = workspace.sessions.find((s) => s.id === id);
return session?.isArchived;
});
}, [selection.selectedIds, workspace.sessions]);
const selectedSessions = useMemo(
() => workspace.sessions.filter((s) => selection.selectedIds.has(s.id)),
[selection.selectedIds, workspace.sessions],
);
function handleEnterSelectionMode(sessionId: string) {
const session = workspace.sessions.find((s) => s.id === sessionId);
if (session?.status === 'running') return;
selection.enterSelectionMode(sessionId);
}
function handleToggleSelection(sessionId: string) {
const session = workspace.sessions.find((s) => s.id === sessionId);
if (session?.status === 'running') return;
selection.toggle(sessionId);
}
function handleShiftSelect(sessionId: string) {
const session = workspace.sessions.find((s) => s.id === sessionId);
if (session?.status === 'running') return;
selection.rangeSelect(sessionId, allVisibleIds);
}
const handleBatchArchive = useCallback(() => {
const ids = [...selection.selectedIds];
const isArchived = !allSelectedArchived;
onBatchArchiveSessions(ids, isArchived);
selection.exitSelectionMode();
setUndoToast({
message: `${ids.length} session${ids.length === 1 ? '' : 's'} ${isArchived ? 'archived' : 'restored'}`,
sessionIds: ids,
isArchived,
});
}, [selection, allSelectedArchived, onBatchArchiveSessions]);
const handleBatchDeleteConfirm = useCallback(() => {
const ids = [...selection.selectedIds];
onBatchDeleteSessions(ids);
selection.exitSelectionMode();
setShowDeleteConfirm(false);
}, [selection, onBatchDeleteSessions]);
const handleUndoArchive = useCallback(() => {
if (!undoToast) return;
onBatchArchiveSessions(undoToast.sessionIds, !undoToast.isArchived);
setUndoToast(null);
}, [undoToast, onBatchArchiveSessions]);
// Exit selection mode on Escape
useEffect(() => {
if (!selection.isSelecting) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
selection.exitSelectionMode();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [selection.isSelecting, selection.exitSelectionMode]);
// Clean up selection when sessions are removed from workspace
useEffect(() => {
if (!selection.isSelecting) return;
const sessionIdSet = new Set(workspace.sessions.map((s) => s.id));
const stale = [...selection.selectedIds].filter((id) => !sessionIdSet.has(id));
if (stale.length > 0) {
for (const id of stale) selection.toggle(id);
}
}, [workspace.sessions, selection]);
return (
<div className="flex h-full flex-col">
{/* Header — extra top padding clears the title bar overlay zone */}
@@ -685,7 +882,7 @@ export function Sidebar({
No sessions match your search
</div>
) : (
queryResults.map((session) => (
queryResults.map((session, index) => (
<SessionItem
isActive={workspace.selectedSessionId === session.id}
isRenaming={renamingSessionId === session.id}
@@ -696,6 +893,12 @@ export function Sidebar({
onRenameCancel={() => setRenamingSessionId(undefined)}
workflow={workflowMap.get(session.workflowId)}
session={session}
isSelecting={selection.isSelecting}
isSelected={selection.isSelected(session.id)}
selectionIndex={index}
onToggleSelection={() => handleToggleSelection(session.id)}
onEnterSelectionMode={() => handleEnterSelectionMode(session.id)}
onShiftSelect={() => handleShiftSelect(session.id)}
/>
))
)}
@@ -721,6 +924,11 @@ export function Sidebar({
sessions={workspace.sessions.filter((session) => session.projectId === scratchpadProject.id)}
onNewSession={onCreateScratchpad}
newSessionLabel="New Scratchpad"
isSelecting={selection.isSelecting}
isSelected={selection.isSelected}
onToggleSelection={handleToggleSelection}
onEnterSelectionMode={handleEnterSelectionMode}
onShiftSelect={handleShiftSelect}
/>
</div>
)}
@@ -769,6 +977,11 @@ export function Sidebar({
selectedSessionId={workspace.selectedSessionId}
sessions={workspace.sessions.filter((session) => session.projectId === project.id)}
onNewSession={() => onNewProjectSession(project.id)}
isSelecting={selection.isSelecting}
isSelected={selection.isSelected}
onToggleSelection={handleToggleSelection}
onEnterSelectionMode={handleEnterSelectionMode}
onShiftSelect={handleShiftSelect}
/>
))}
</div>
@@ -801,7 +1014,7 @@ export function Sidebar({
)}
{/* Context menu overlay */}
{menuState && menuSession && (
{menuState && menuSession && !selection.isSelecting && (
<>
<div className="fixed inset-0 z-40" onClick={closeMenu} onKeyDown={(e) => { if (e.key === 'Escape') closeMenu(); }} />
<div
@@ -853,6 +1066,38 @@ export function Sidebar({
</div>
</>
)}
{/* Batch action bar */}
{selection.isSelecting && selection.selectedIds.size > 0 && (
<BatchActionBar
selectedCount={selection.selectedIds.size}
allSelectedArchived={allSelectedArchived}
allSelected={allSelectableIds.length > 0 && allSelectableIds.every((id) => selection.selectedIds.has(id))}
onArchive={handleBatchArchive}
onDelete={() => setShowDeleteConfirm(true)}
onSelectAll={() => selection.selectAll(allSelectableIds)}
onDeselectAll={selection.deselectAll}
onCancel={selection.exitSelectionMode}
/>
)}
{/* Undo toast */}
{undoToast && (
<UndoToast
message={undoToast.message}
onUndo={handleUndoArchive}
onDismiss={() => setUndoToast(null)}
/>
)}
{/* Batch delete confirmation */}
{showDeleteConfirm && selectedSessions.length > 0 && (
<BatchDeleteConfirmDialog
sessions={selectedSessions}
onConfirm={handleBatchDeleteConfirm}
onCancel={() => setShowDeleteConfirm(false)}
/>
)}
</div>
);
}
+40 -1
View File
@@ -13,6 +13,7 @@ import type {
SubWorkflowConfig,
WorkflowStateScope,
} from '@shared/domain/workflow';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import { validateWorkflowDefinition, isBuilderBasedMode, syncBuilderModeEdgeIterations } from '@shared/domain/workflow';
import { createId } from '@shared/utils/ids';
import { ToggleSwitch } from '@renderer/components/ui';
@@ -25,6 +26,7 @@ import { OrchestrationModePanel } from './workflow/OrchestrationModePanel';
interface WorkflowEditorProps {
availableModels: ReadonlyArray<ModelDefinition>;
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>;
workflow: WorkflowDefinition;
workflows: ReadonlyArray<WorkflowDefinition>;
onChange: (workflow: WorkflowDefinition) => void;
@@ -152,6 +154,7 @@ function createMinimalInlineWorkflow(): WorkflowDefinition {
export function WorkflowEditor({
availableModels,
workspaceAgents,
workflow,
workflows,
onChange,
@@ -251,6 +254,36 @@ export function WorkflowEditor({
setSelectedEdgeId(null);
}
function handleAddWorkspaceAgentNode(agentId: string) {
const agent = workspaceAgents.find((a) => a.id === agentId);
if (!agent) return;
const nodeId = createId('wf-agent');
const config: AgentNodeConfig = {
kind: 'agent',
id: createId('agent'),
name: agent.name,
description: agent.description,
instructions: agent.instructions,
model: agent.model,
reasoningEffort: agent.reasoningEffort,
copilot: agent.copilot,
workspaceAgentId: agent.id,
};
const newNode: WorkflowNode = {
id: nodeId,
kind: 'agent',
label: agent.name,
position: { x: 300, y: 200 },
config,
};
emitGraphChange({
...activeWorkflow.graph,
nodes: [...activeWorkflow.graph.nodes, newNode],
});
setSelectedNodeId(nodeId);
setSelectedEdgeId(null);
}
function handleNodeChange(nodeId: string, patch: Partial<WorkflowNode>) {
emitGraphChange({
...activeWorkflow.graph,
@@ -486,7 +519,12 @@ export function WorkflowEditor({
<div className="flex min-h-0 flex-1">
{/* Left palette */}
<div className="w-40 shrink-0 overflow-y-auto border-r border-[var(--color-border)] bg-[var(--color-surface-1)]">
<WorkflowNodePalette disabledKinds={disabledPaletteKinds} onAddNode={handleAddNode} />
<WorkflowNodePalette
disabledKinds={disabledPaletteKinds}
onAddNode={handleAddNode}
onAddWorkspaceAgentNode={handleAddWorkspaceAgentNode}
workspaceAgents={workspaceAgents}
/>
</div>
{/* Center column: validation + canvas + settings */}
@@ -551,6 +589,7 @@ export function WorkflowEditor({
validationIssues={issues}
workflow={activeWorkflow}
workflows={workflows}
workspaceAgents={workspaceAgents}
/>
</div>
</div>
@@ -5,12 +5,12 @@ import type { ActiveSubagent } from '@renderer/lib/subagentTracker';
const COMPLETION_GRACE_MS = 3000;
function formatElapsed(startedAt: string): string {
const seconds = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return `${minutes}m ${remainder}s`;
import { formatElapsedMs } from '@renderer/hooks/useElapsedTimer';
function formatElapsed(startedAt: string, endedAt?: string): string {
const endMs = endedAt ? new Date(endedAt).getTime() : Date.now();
const durationMs = endMs - new Date(startedAt).getTime();
return formatElapsedMs(durationMs);
}
function StatusIcon({ status }: { status: ActiveSubagent['status'] }) {
@@ -153,12 +153,12 @@ function ActivityTimelineEventRow({ event }: { event: RunTimelineEventRecord })
const isTerminal = event.kind === 'run-completed' || event.kind === 'run-cancelled' || event.kind === 'run-failed';
return (
<div className="turn-activity-row flex gap-2 py-1">
<div className="mt-0.5 flex shrink-0 items-start">
<div className="turn-activity-row flex items-start gap-2 py-1">
<div className="flex h-[18px] w-4 shrink-0 items-center justify-center">
<ActivityEventIcon kind={event.kind} status={event.status} toolName={event.toolName} />
</div>
<div className="min-w-0 flex-1">
<span className={`text-[12px] font-medium ${isTerminal ? 'text-[var(--color-text-muted)]' : 'text-[var(--color-text-secondary)]'}`}>
<span className={`text-[12px] leading-[18px] font-medium ${isTerminal ? 'text-[var(--color-text-muted)]' : 'text-[var(--color-text-secondary)]'}`}>
{label}
</span>
@@ -231,14 +231,14 @@ function GroupedToolCallRow({ toolName, events }: { toolName: string; events: Ru
<div className="turn-activity-row py-0.5">
<button
type="button"
className="flex w-full items-start gap-2 py-1 text-left transition-colors hover:bg-[var(--color-surface-2)]/30 rounded px-1 -mx-1"
className="flex w-full items-start gap-2 rounded px-1 -mx-1 py-1 text-left transition-colors hover:bg-[var(--color-surface-2)]/30"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
>
<div className="mt-0.5 flex shrink-0 items-start">
<div className="flex h-[18px] w-4 shrink-0 items-center justify-center">
<ToolCategoryIcon toolName={toolName} />
</div>
<span className="min-w-0 flex-1 text-[12px] font-medium text-[var(--color-text-secondary)]">
<span className="min-w-0 flex-1 text-[12px] leading-[18px] font-medium text-[var(--color-text-secondary)]">
{label}
</span>
<ChevronRight
@@ -250,7 +250,7 @@ function GroupedToolCallRow({ toolName, events }: { toolName: string; events: Ru
{/* Collapsed preview: show snippets inline */}
{!expanded && snippets.length > 0 && (
<div className="ml-5 flex flex-wrap gap-x-2 gap-y-0.5 pb-0.5">
<div className="ml-6 flex flex-wrap gap-x-2 gap-y-0.5 pb-0.5">
{snippets.slice(0, 6).map((s, i) => (
<span key={i} className="truncate font-mono text-[10px] text-[var(--color-text-muted)]">
{s}
@@ -266,7 +266,7 @@ function GroupedToolCallRow({ toolName, events }: { toolName: string; events: Ru
{/* Expanded: full per-event rows */}
{expanded && (
<div className="ml-5 border-l border-[var(--color-border)]/30 pl-2">
<div className="ml-6 border-l border-[var(--color-border)]/30 pl-2">
{events.map((event) => (
<div key={event.id} className="py-0.5">
<span className="text-[11px] text-[var(--color-text-secondary)]">
@@ -285,7 +285,7 @@ function GroupedToolCallRow({ toolName, events }: { toolName: string; events: Ru
{/* Aggregate file changes when collapsed */}
{!expanded && hasFileChanges && (
<div className="ml-5 mt-0.5">
<div className="ml-6 mt-0.5">
{events
.filter((e) => e.fileChanges && e.fileChanges.length > 0)
.flatMap((e) => e.fileChanges!)
@@ -304,9 +304,9 @@ function GroupedToolCallRow({ toolName, events }: { toolName: string; events: Ru
function IntentDividerRow({ text }: { text: string }) {
return (
<div className="turn-activity-row flex items-center gap-2 py-1.5" role="separator">
<div className="turn-activity-row flex items-center gap-2 py-2" role="separator">
<div className="h-px flex-1 bg-[var(--color-border)]/40" />
<span className="shrink-0 text-[10px] font-medium tracking-wide text-[var(--color-text-muted)]">
<span className="shrink-0 rounded-full bg-[var(--color-surface-2)]/60 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-[var(--color-text-muted)]">
{text}
</span>
<div className="h-px flex-1 bg-[var(--color-border)]/40" />
@@ -322,8 +322,8 @@ function ThinkingStepRow({ message }: { message: ChatMessageRecord }) {
if (message.pending && !message.content) return null;
return (
<div className="turn-activity-row flex gap-2 py-1">
<div className="mt-0.5 flex shrink-0 items-start">
<div className="turn-activity-row flex items-start gap-2 py-1">
<div className="flex h-[18px] w-4 shrink-0 items-center justify-center">
<Brain className="size-3 text-[var(--color-accent-purple)]" />
</div>
<div className="min-w-0 flex-1">
@@ -349,8 +349,8 @@ function ThinkingGroupRow({ messages }: { messages: ChatMessageRecord[] }) {
return (
<div className="turn-activity-row py-0.5">
<div className="flex gap-2 py-1">
<div className="mt-0.5 flex shrink-0 items-start">
<div className="flex items-start gap-2 py-1">
<div className="flex h-[18px] w-4 shrink-0 items-center justify-center">
<Brain className="size-3 text-[var(--color-accent-purple)]" />
</div>
<div className="min-w-0 flex-1">
@@ -370,7 +370,7 @@ function ThinkingGroupRow({ messages }: { messages: ChatMessageRecord[] }) {
</div>
{expanded && (
<div className="ml-5 space-y-0.5 border-l border-[var(--color-border)]/30 pl-2">
<div className="ml-6 space-y-0.5 border-l border-[var(--color-border)]/30 pl-2">
{visibleMessages.slice(0, -1).map((msg) => (
<p key={msg.id} className="text-[10px] italic leading-snug text-[var(--color-text-muted)]">
"{truncatePreview(msg.content, 140)}"
@@ -461,6 +461,7 @@ export function TurnActivityPanel({
const elapsed = useElapsedTimer(
thinkingMessages.length > 0 || run ? effectiveTurnStartedAt : undefined,
isActive,
run?.completedAt,
);
const summary = useMemo(
@@ -516,17 +517,25 @@ export function TurnActivityPanel({
: isCancelled
? 'text-[var(--color-text-muted)]'
: isActive
? 'text-[var(--color-text-secondary)]'
? 'text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)]';
const statusDotClass = isFailed
? 'bg-[var(--color-status-error)]'
: isCancelled
? 'bg-[var(--color-text-muted)]'
: isActive
? 'bg-[var(--color-accent)]'
: 'bg-[var(--color-status-success)]';
return (
<div
className={`turn-activity-enter overflow-hidden rounded-lg border bg-[var(--color-surface-1)]/60 transition-colors duration-200 ${
className={`turn-activity-enter overflow-hidden rounded-lg border transition-colors duration-200 ${
isActive
? 'border-[var(--color-accent)]/30'
? 'border-[var(--color-accent)]/30 bg-[var(--color-accent)]/[0.03]'
: isFailed
? 'border-[var(--color-status-error)]/20'
: 'border-[var(--color-border)]/50'
? 'border-[var(--color-status-error)]/20 bg-[var(--color-surface-1)]/60'
: 'border-[var(--color-border)]/50 bg-[var(--color-surface-1)]/60'
}`}
>
{/* Summary header */}
@@ -535,20 +544,23 @@ export function TurnActivityPanel({
onClick={toggle}
onKeyDown={(e) => { if (e.key === ' ') { e.preventDefault(); toggle(); } }}
aria-expanded={expanded}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] transition-colors hover:bg-[var(--color-surface-2)]/50 ${
isActive ? 'bg-[var(--color-accent)]/[0.04]' : ''
}`}
className="flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-2)]/50"
>
<Zap className={`size-3.5 shrink-0 ${isActive ? 'text-[var(--color-accent)]' : 'text-[var(--color-text-muted)]'}`} />
{/* Status dot */}
<span className={`size-2 shrink-0 rounded-full ${statusDotClass} ${isActive ? 'animate-pulse' : ''}`} />
{isActive ? (
<span className="flex items-center gap-1.5">
<span className={statusColorClass}>{summaryLabel}</span>
<ActivityPulse />
{/* Status label + elapsed */}
<span className="flex items-center gap-1.5">
<span className={`text-[12px] font-semibold ${statusColorClass}`}>
{summaryLabel}
</span>
) : (
<span className={statusColorClass}>{summaryLabel}</span>
)}
{isActive && <ActivityPulse />}
{elapsed && (
<span className="tabular-nums text-[11px] text-[var(--color-text-muted)]">
{isActive ? elapsed : ''}
</span>
)}
</span>
{/* Intent / generated summary */}
{headerDetail && (
@@ -560,7 +572,7 @@ export function TurnActivityPanel({
{/* Inline counters */}
{summaryParts.length > 0 && (
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]">
<span className="shrink-0 tabular-nums text-[10px] text-[var(--color-text-muted)]">
{'· '}
{summaryParts.join(' · ')}
</span>
@@ -576,7 +588,7 @@ export function TurnActivityPanel({
{/* Expanded activity stream — grouped */}
{expanded && (
<div className="border-t border-[var(--color-border)]/30 px-3 py-2">
<div className="space-y-0.5">
<div className="activity-timeline-spine relative space-y-px">
{groupedItems.map((item, index) => (
<GroupedItemRow key={index} item={item} />
))}
@@ -1,4 +1,4 @@
import { useEffect, useRef, useMemo } from 'react';
import { useCallback, useEffect, useRef, useMemo, useState } from 'react';
import { Check, Brain } from 'lucide-react';
import { ProviderIcon } from '@renderer/components/ProviderIcons';
@@ -37,6 +37,55 @@ export function ModelSelector({
onClose,
}: ModelSelectorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const flatModels = useMemo(() => [...models], [models]);
const initialIndex = flatModels.findIndex((m) => m.id === selectedModelId);
const [focusedIndex, setFocusedIndex] = useState(initialIndex >= 0 ? initialIndex : 0);
const optionRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
// Scroll the focused option into view whenever it changes
useEffect(() => {
optionRefs.current.get(focusedIndex)?.scrollIntoView({ block: 'nearest' });
}, [focusedIndex]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown': {
e.preventDefault();
setFocusedIndex((prev) => (prev + 1) % flatModels.length);
break;
}
case 'ArrowUp': {
e.preventDefault();
setFocusedIndex((prev) => (prev - 1 + flatModels.length) % flatModels.length);
break;
}
case 'Home': {
e.preventDefault();
setFocusedIndex(0);
break;
}
case 'End': {
e.preventDefault();
setFocusedIndex(flatModels.length - 1);
break;
}
case 'Enter':
case ' ': {
e.preventDefault();
const model = flatModels[focusedIndex];
if (model) onSelect(model);
break;
}
case 'Escape': {
e.stopPropagation();
onClose();
break;
}
}
},
[flatModels, focusedIndex, onSelect, onClose],
);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
@@ -44,21 +93,18 @@ export function ModelSelector({
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape, true);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape, true);
};
}, [onClose]);
// Focus the container on mount so keyboard events are captured immediately
useEffect(() => {
containerRef.current?.focus();
}, []);
const selectedModel = models.find((m) => m.id === selectedModelId);
const supportedEfforts = selectedModel?.supportedReasoningEfforts;
@@ -96,12 +142,19 @@ export function ModelSelector({
return result;
}, [models]);
// Build a flat index for each model so we can map group-based rendering
// back to the flat focusedIndex.
let flatIndex = -1;
return (
<div
ref={containerRef}
className="qp-dropdown-enter absolute top-full left-3 right-3 z-10 mt-1 overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-2xl shadow-black/50"
className="qp-dropdown-enter absolute top-full left-3 right-3 z-10 mt-1 overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] outline-none"
role="listbox"
aria-label="Select model"
aria-activedescendant={flatModels[focusedIndex] ? `model-option-${flatModels[focusedIndex].id}` : undefined}
tabIndex={0}
onKeyDown={handleKeyDown}
>
{/* Model list — grouped by provider */}
<div className="max-h-[280px] overflow-y-auto overscroll-contain p-1.5">
@@ -121,7 +174,10 @@ export function ModelSelector({
{/* Models in this provider group */}
{group.models.map((model) => {
flatIndex++;
const modelIndex = flatIndex;
const isSelected = model.id === selectedModelId;
const isFocused = modelIndex === focusedIndex;
const tierLabel = model.tier === 'premium' ? 'PRO' : model.tier === 'fast' ? 'FAST' : undefined;
const tierColor = model.tier === 'premium'
? 'text-amber-400 bg-amber-400/10'
@@ -132,15 +188,24 @@ export function ModelSelector({
return (
<button
key={model.id}
id={`model-option-${model.id}`}
ref={(el) => {
if (el) optionRefs.current.set(modelIndex, el);
else optionRefs.current.delete(modelIndex);
}}
onClick={() => onSelect(model)}
onMouseEnter={() => setFocusedIndex(modelIndex)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-[7px] text-left transition-colors ${
isSelected
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-primary)]'
isFocused
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)]'
: isSelected
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)]'
}`}
type="button"
role="option"
aria-selected={isSelected}
tabIndex={-1}
>
<span className="flex-1 truncate text-[12px] font-medium">{model.name}</span>
@@ -0,0 +1,92 @@
import { Archive, ArchiveRestore, CheckSquare, Square, Trash2, X } from 'lucide-react';
interface BatchActionBarProps {
selectedCount: number;
allSelectedArchived: boolean;
onArchive: () => void;
onDelete: () => void;
onSelectAll: () => void;
onDeselectAll: () => void;
onCancel: () => void;
allSelected: boolean;
}
export function BatchActionBar({
selectedCount,
allSelectedArchived,
onArchive,
onDelete,
onSelectAll,
onDeselectAll,
onCancel,
allSelected,
}: BatchActionBarProps) {
const archiveLabel = allSelectedArchived ? 'Restore' : 'Archive';
const ArchiveIcon = allSelectedArchived ? ArchiveRestore : Archive;
return (
<div
className="batch-action-bar-enter border-t border-[var(--color-border)] bg-[var(--color-surface-1)]/95 px-3 py-2.5 backdrop-blur-md"
role="toolbar"
aria-label="Batch session actions"
>
{/* Top row — selection count + select all/none */}
<div className="mb-2 flex items-center justify-between">
<span
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--color-accent)]/15 px-2.5 py-1 text-[11px] font-semibold text-[var(--color-accent)]"
aria-live="polite"
>
{selectedCount} selected
</span>
<button
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-primary)]"
onClick={allSelected ? onDeselectAll : onSelectAll}
type="button"
>
{allSelected ? (
<>
<Square className="size-3" />
None
</>
) : (
<>
<CheckSquare className="size-3" />
All
</>
)}
</button>
</div>
{/* Bottom row — action buttons */}
<div className="flex items-center gap-1.5">
<button
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-[var(--color-surface-2)] px-3 py-1.5 text-[12px] font-medium text-[var(--color-text-primary)] transition-all duration-150 hover:bg-[var(--color-surface-3)]"
onClick={onArchive}
type="button"
title={`${archiveLabel} ${selectedCount} session${selectedCount === 1 ? '' : 's'}`}
>
<ArchiveIcon className="size-3.5" />
{archiveLabel}
</button>
<button
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-[var(--color-status-error)]/10 px-3 py-1.5 text-[12px] font-medium text-[var(--color-status-error)] transition-all duration-150 hover:bg-[var(--color-status-error)]/20"
onClick={onDelete}
type="button"
title={`Delete ${selectedCount} session${selectedCount === 1 ? '' : 's'}`}
>
<Trash2 className="size-3.5" />
Delete
</button>
<button
className="flex size-7 shrink-0 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition-all duration-150 hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-primary)]"
onClick={onCancel}
type="button"
aria-label="Exit multi-select"
title="Exit multi-select (Esc)"
>
<X className="size-3.5" />
</button>
</div>
</div>
);
}
@@ -0,0 +1,218 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { AlertTriangle, Trash2, X } from 'lucide-react';
import type { SessionRecord } from '@shared/domain/session';
interface BatchDeleteConfirmDialogProps {
sessions: SessionRecord[];
onConfirm: () => void;
onCancel: () => void;
}
const HOLD_DURATION_MS = 1500;
const HOLD_THRESHOLD = 3;
export function BatchDeleteConfirmDialog({
sessions,
onConfirm,
onCancel,
}: BatchDeleteConfirmDialogProps) {
const requiresHold = sessions.length >= HOLD_THRESHOLD;
const [holdProgress, setHoldProgress] = useState(0);
const holdTimerRef = useRef<number | null>(null);
const holdStartRef = useRef<number | null>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// Focus trap
useEffect(() => {
const el = dialogRef.current;
if (!el) return;
const focusable = el.querySelectorAll<HTMLElement>(
'button, [tabindex]:not([tabindex="-1"])',
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
first?.focus();
function handleTab(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
if (!first || !last) return;
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.stopPropagation();
onCancel();
}
}
document.addEventListener('keydown', handleTab);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('keydown', handleTab);
document.removeEventListener('keydown', handleEscape);
};
}, [onCancel]);
const startHold = useCallback(() => {
if (!requiresHold) return;
holdStartRef.current = performance.now();
const tick = () => {
if (!holdStartRef.current) return;
const elapsed = performance.now() - holdStartRef.current;
const progress = Math.min(elapsed / HOLD_DURATION_MS, 1);
setHoldProgress(progress);
if (progress >= 1) {
onConfirm();
return;
}
holdTimerRef.current = requestAnimationFrame(tick);
};
holdTimerRef.current = requestAnimationFrame(tick);
}, [requiresHold, onConfirm]);
const cancelHold = useCallback(() => {
holdStartRef.current = null;
if (holdTimerRef.current !== null) {
cancelAnimationFrame(holdTimerRef.current);
holdTimerRef.current = null;
}
setHoldProgress(0);
}, []);
// Clean up on unmount
useEffect(() => {
return () => {
if (holdTimerRef.current !== null) cancelAnimationFrame(holdTimerRef.current);
};
}, []);
return (
<>
{/* Backdrop */}
<div
className="overlay-backdrop-enter fixed inset-0 z-50 bg-black/60"
onClick={onCancel}
aria-hidden
/>
{/* Dialog */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="presentation">
<div
ref={dialogRef}
className="overlay-panel-enter w-full max-w-sm rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-[0_24px_80px_rgba(0,0,0,0.5)]"
role="alertdialog"
aria-modal="true"
aria-labelledby="batch-delete-title"
aria-describedby="batch-delete-desc"
>
{/* Header */}
<div className="flex items-start justify-between p-5 pb-3">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-xl bg-[var(--color-status-error)]/10">
<Trash2 className="size-5 text-[var(--color-status-error)]" />
</div>
<div>
<h2
id="batch-delete-title"
className="text-[15px] font-semibold text-[var(--color-text-primary)]"
>
Delete {sessions.length} session{sessions.length === 1 ? '' : 's'}?
</h2>
</div>
</div>
<button
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-primary)]"
onClick={onCancel}
type="button"
aria-label="Cancel"
>
<X className="size-4" />
</button>
</div>
{/* Session list */}
<div className="px-5">
<div className="max-h-[200px] overflow-y-auto rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-0)]/60 px-3 py-2">
{sessions.map((session) => (
<div
key={session.id}
className="truncate py-1 text-[12px] text-[var(--color-text-secondary)]"
>
{session.title}
</div>
))}
</div>
</div>
{/* Warning */}
<div className="px-5 pt-3" id="batch-delete-desc">
<div className="flex items-start gap-2 rounded-lg bg-[var(--color-status-error)]/5 px-3 py-2 text-[12px] text-[var(--color-status-error)]">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>This action cannot be undone. All messages and session data will be permanently removed.</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 p-5">
<button
className="rounded-lg px-4 py-2 text-[13px] font-medium text-[var(--color-text-secondary)] transition hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-primary)]"
onClick={onCancel}
type="button"
>
Cancel
</button>
{requiresHold ? (
<button
className="hold-to-confirm relative overflow-hidden rounded-lg bg-[var(--color-status-error)] px-4 py-2 text-[13px] font-medium text-white transition-all select-none"
onMouseDown={startHold}
onMouseUp={cancelHold}
onMouseLeave={cancelHold}
onTouchStart={startHold}
onTouchEnd={cancelHold}
type="button"
>
{/* Progress fill */}
<span
className="absolute inset-0 origin-left bg-white/20 transition-none"
style={{ transform: `scaleX(${holdProgress})` }}
aria-hidden
/>
<span className="relative flex items-center gap-1.5">
<Trash2 className="size-3.5" />
{holdProgress > 0 ? 'Hold to delete…' : 'Hold to delete'}
</span>
</button>
) : (
<button
className="rounded-lg bg-[var(--color-status-error)] px-4 py-2 text-[13px] font-medium text-white transition hover:bg-[var(--color-status-error)]/80"
onClick={onConfirm}
type="button"
>
<span className="flex items-center gap-1.5">
<Trash2 className="size-3.5" />
Delete
</span>
</button>
)}
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,77 @@
import { useEffect, useRef, useState } from 'react';
import { Undo2, X } from 'lucide-react';
interface UndoToastProps {
message: string;
onUndo: () => void;
onDismiss: () => void;
duration?: number;
}
export function UndoToast({
message,
onUndo,
onDismiss,
duration = 5000,
}: UndoToastProps) {
const [exiting, setExiting] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
timerRef.current = setTimeout(() => {
setExiting(true);
setTimeout(onDismiss, 200);
}, duration);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [duration, onDismiss]);
function handleUndo() {
if (timerRef.current) clearTimeout(timerRef.current);
onUndo();
}
function handleDismiss() {
if (timerRef.current) clearTimeout(timerRef.current);
setExiting(true);
setTimeout(onDismiss, 200);
}
return (
<div
className={`${exiting ? 'undo-toast-exit' : 'undo-toast-enter'} pointer-events-auto mx-3 mb-2 flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)]/95 px-3 py-2.5 shadow-[0_8px_32px_rgba(0,0,0,0.3)] backdrop-blur-md`}
role="alert"
>
<span className="flex-1 text-[12px] text-[var(--color-text-primary)]">
{message}
</span>
<button
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-semibold text-[var(--color-accent)] transition hover:bg-[var(--color-accent)]/10"
onClick={handleUndo}
type="button"
>
<Undo2 className="size-3" />
Undo
</button>
<button
className="flex size-5 items-center justify-center rounded text-[var(--color-text-muted)] transition hover:text-[var(--color-text-primary)]"
onClick={handleDismiss}
type="button"
aria-label="Dismiss"
>
<X className="size-3" />
</button>
{/* Auto-dismiss progress bar */}
<span
className="absolute bottom-0 left-0 right-0 h-[2px] origin-left rounded-b-xl bg-[var(--color-accent)]/30"
style={{
animation: `toast-progress ${duration}ms linear forwards`,
}}
aria-hidden
/>
</div>
);
}
@@ -0,0 +1,222 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { isMac } from '@renderer/lib/platform';
/** Modifier keys we recognise, in canonical display order. */
const MODIFIER_KEYS = new Set(['Control', 'Alt', 'Shift', 'Meta']);
/** Keys that should never be accepted as the primary (non-modifier) key. */
const IGNORED_KEYS = new Set([
'Dead', 'Unidentified', 'Process', 'CapsLock', 'NumLock', 'ScrollLock',
'Fn', 'FnLock', 'Hyper', 'Super', 'OS',
]);
/**
* Convert a portable hotkey string (e.g. `"Alt+Shift+C"`) into
* platform-aware display tokens.
*/
export function hotkeyToDisplayTokens(hotkey: string): string[] {
return hotkey
.replace(/\bSuper\b/g, isMac ? '⌘' : 'Win')
.replace(/\bMeta\b/g, isMac ? '⌘' : 'Win')
.replace(/\bCtrl\b/g, isMac ? '⌃' : 'Ctrl')
.replace(/\bControl\b/g, isMac ? '⌃' : 'Ctrl')
.replace(/\bAlt\b/g, isMac ? '⌥' : 'Alt')
.replace(/\bShift\b/g, isMac ? '⇧' : 'Shift')
.split('+')
.map((k) => k.trim());
}
/**
* Normalise a `KeyboardEvent` key name into the portable format used in
* settings (Electron accelerator-compatible).
*/
function normaliseKeyName(key: string): string | undefined {
if (MODIFIER_KEYS.has(key) || IGNORED_KEYS.has(key)) return undefined;
// Letters → uppercase
if (/^[a-zA-Z]$/.test(key)) return key.toUpperCase();
// Digits
if (/^[0-9]$/.test(key)) return key;
// F-keys
if (/^F\d{1,2}$/.test(key)) return key;
// Named keys
const named: Record<string, string> = {
' ': 'Space', Enter: 'Enter', Tab: 'Tab', Escape: 'Escape',
Backspace: 'Backspace', Delete: 'Delete', Insert: 'Insert',
Home: 'Home', End: 'End', PageUp: 'PageUp', PageDown: 'PageDown',
ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right',
'+': 'Plus', '-': 'Minus', '=': 'Equal',
'[': 'BracketLeft', ']': 'BracketRight',
'\\': 'Backslash', '/': 'Slash',
';': 'Semicolon', "'": 'Quote', ',': 'Comma', '.': 'Period',
'`': 'Backquote',
};
return named[key] ?? key;
}
/** Build the portable hotkey string from modifier flags and a key name. */
function buildHotkeyString(mods: { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }, key: string): string {
const parts: string[] = [];
if (mods.ctrl) parts.push('Ctrl');
if (mods.alt) parts.push('Alt');
if (mods.shift) parts.push('Shift');
if (mods.meta) parts.push('Super');
parts.push(key);
return parts.join('+');
}
interface HotkeyRecorderProps {
value: string;
onChange: (hotkey: string) => void;
disabled?: boolean;
}
export function HotkeyRecorder({ value, onChange, disabled }: HotkeyRecorderProps) {
const [recording, setRecording] = useState(false);
const [liveKeys, setLiveKeys] = useState<string | null>(null);
const recorderRef = useRef<HTMLButtonElement>(null);
const displayTokens = hotkeyToDisplayTokens(liveKeys ?? value);
const stopRecording = useCallback(() => {
setRecording(false);
setLiveKeys(null);
}, []);
// Escape cancels recording
// Valid combo (modifier + key) commits immediately
useEffect(() => {
if (!recording) return;
function handleKeyDown(e: KeyboardEvent) {
e.preventDefault();
e.stopPropagation();
// Escape cancels
if (e.key === 'Escape') {
stopRecording();
return;
}
const mods = { ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey };
const hasModifier = mods.ctrl || mods.alt || mods.shift || mods.meta;
const key = normaliseKeyName(e.key);
if (key && hasModifier) {
const hotkey = buildHotkeyString(mods, key);
onChange(hotkey);
setRecording(false);
setLiveKeys(null);
} else if (!key) {
// Only modifiers held — show live preview
const preview = buildModifierPreview(mods);
if (preview) setLiveKeys(preview);
}
}
function handleKeyUp(e: KeyboardEvent) {
e.preventDefault();
e.stopPropagation();
}
window.addEventListener('keydown', handleKeyDown, true);
window.addEventListener('keyup', handleKeyUp, true);
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
window.removeEventListener('keyup', handleKeyUp, true);
};
}, [recording, onChange, stopRecording]);
// Click outside cancels recording
useEffect(() => {
if (!recording) return;
function handleClick(e: MouseEvent) {
if (recorderRef.current && !recorderRef.current.contains(e.target as Node)) {
stopRecording();
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [recording, stopRecording]);
return (
<button
ref={recorderRef}
type="button"
disabled={disabled}
onClick={() => {
if (!disabled) {
setRecording(true);
setLiveKeys(null);
}
}}
aria-label={recording ? 'Press a key combination to set shortcut' : `Change shortcut, currently ${value}`}
className={`
group relative flex items-center gap-2.5 rounded-lg border px-4 py-3 text-left
transition-all duration-200 outline-none
${recording
? 'border-[var(--color-accent)]/50 bg-[var(--color-accent)]/[0.06] ring-1 ring-[var(--color-accent)]/20'
: disabled
? 'cursor-not-allowed border-[var(--color-border)] opacity-50'
: 'border-[var(--color-border)] hover:border-[var(--color-border-glow)] hover:bg-[var(--color-surface-3)]/30 cursor-pointer'
}
`}
>
{/* Key display */}
<div className="flex flex-1 items-center gap-1.5">
{recording && !liveKeys ? (
<span className="flex items-center gap-2 text-[12px] text-[var(--color-text-accent)]">
<span className="relative flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-[var(--color-accent)] opacity-40" />
<span className="relative inline-flex size-2 rounded-full bg-[var(--color-accent)]" />
</span>
Press a key combo
</span>
) : (
displayTokens.map((key, i) => (
<kbd
key={`${key}-${i}`}
className={`
rounded-[5px] border px-2 py-[3px] font-mono text-[11px] font-medium leading-none shadow-sm
transition-all duration-200
${recording
? 'border-[var(--color-accent)]/30 bg-[var(--color-accent)]/10 text-[var(--color-text-accent)] shadow-[var(--color-accent)]/10'
: 'border-[var(--color-border)] bg-[var(--color-surface-2)] text-[var(--color-text-secondary)] shadow-black/20'
}
`}
>
{key}
</kbd>
))
)}
</div>
{/* Action hint */}
<span
className={`
shrink-0 rounded-md px-2 py-1 text-[10px] font-semibold tracking-wide uppercase
transition-all duration-200
${recording
? 'bg-[var(--color-accent)]/15 text-[var(--color-text-accent)]'
: 'text-[var(--color-text-muted)] opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100'
}
`}
>
{recording ? 'Esc to cancel' : 'Click to change'}
</span>
</button>
);
}
function buildModifierPreview(mods: { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }): string | null {
const parts: string[] = [];
if (mods.ctrl) parts.push('Ctrl');
if (mods.alt) parts.push('Alt');
if (mods.shift) parts.push('Shift');
if (mods.meta) parts.push('Super');
return parts.length > 0 ? parts.join('+') + '+…' : null;
}
+1
View File
@@ -7,5 +7,6 @@ export { TextInput } from './TextInput';
export { TextareaInput } from './TextareaInput';
export { SelectInput } from './SelectInput';
export { InfoCallout } from './InfoCallout';
export { HotkeyRecorder, hotkeyToDisplayTokens } from './HotkeyRecorder';
export type { UpdateBannerProps } from './UpdateBanner';
export { UpdateBanner } from './UpdateBanner';
@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { AlertCircle, Bot, FunctionSquare, GitBranch, Info, Radio, Trash2 } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { AlertCircle, Bot, FunctionSquare, GitBranch, Info, Link2, Radio, RotateCcw, Trash2, Unlink } from 'lucide-react';
import {
findModel,
@@ -16,8 +16,10 @@ import type {
WorkflowOrchestrationMode,
WorkflowValidationIssue,
AgentNodeConfig,
WorkflowAgentOverrides,
} from '@shared/domain/workflow';
import { isBuilderBasedMode } from '@shared/domain/workflow';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields';
import { InvokeFunctionInspector } from '@renderer/components/workflow/InvokeFunctionInspector';
import { ConditionEditor } from '@renderer/components/workflow/ConditionEditor';
@@ -26,6 +28,7 @@ import { SubWorkflowInspector } from '@renderer/components/workflow/SubWorkflowI
interface WorkflowGraphInspectorProps {
availableModels: ReadonlyArray<ModelDefinition>;
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>;
workflow: WorkflowDefinition;
workflows: ReadonlyArray<WorkflowDefinition>;
selectedNodeId: string | null;
@@ -39,6 +42,12 @@ interface WorkflowGraphInspectorProps {
onDrillIntoSubWorkflow: (node: WorkflowNode) => void;
}
const inputBaseClass =
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50';
const selectClass =
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition focus:border-[var(--color-accent)]/50';
function InputField({
label,
value,
@@ -52,21 +61,19 @@ function InputField({
multiline?: boolean;
placeholder?: string;
}) {
const base =
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50';
return (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
{multiline ? (
<textarea
className={`${base} min-h-20 resize-y`}
className={`${inputBaseClass} min-h-20 resize-y`}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
/>
) : (
<input
className={base}
className={inputBaseClass}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
@@ -76,37 +83,176 @@ function InputField({
);
}
/* ── Overridable field ─────────────────────────────────────── */
function OverridableField({
label,
baseValue,
overrideValue,
onChange,
onReset,
multiline,
placeholder,
}: {
label: string;
baseValue: string;
overrideValue: string | undefined;
onChange: (value: string) => void;
onReset: () => void;
multiline?: boolean;
placeholder?: string;
}) {
const isOverridden = overrideValue !== undefined;
const displayValue = overrideValue ?? baseValue;
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
{isOverridden && (
<button
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)] transition hover:bg-[var(--color-accent)]/10"
onClick={onReset}
title="Reset to saved agent value"
type="button"
>
<RotateCcw className="size-2.5" />
Reset
</button>
)}
</div>
{multiline ? (
<textarea
className={`${inputBaseClass} min-h-20 resize-y ${isOverridden ? 'border-[var(--color-accent)]/30' : ''}`}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? baseValue}
value={displayValue}
/>
) : (
<input
className={`${inputBaseClass} ${isOverridden ? 'border-[var(--color-accent)]/30' : ''}`}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? baseValue}
value={displayValue}
/>
)}
</div>
);
}
/* ── Agent node inspector ──────────────────────────────────── */
function AgentNodeInspector({
node,
availableModels,
workspaceAgents,
onNodeChange,
onNodeConfigChange,
onNodeRemove,
}: {
node: WorkflowNode;
availableModels: ReadonlyArray<ModelDefinition>;
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>;
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void;
onNodeRemove: (nodeId: string) => void;
}) {
const config = node.config as AgentNodeConfig;
const model = findModel(config.model, availableModels);
const isLinked = !!config.workspaceAgentId;
const baseAgent = useMemo(
() => isLinked ? workspaceAgents.find((a) => a.id === config.workspaceAgentId) : undefined,
[isLinked, config.workspaceAgentId, workspaceAgents],
);
const isStale = isLinked && !baseAgent;
const resolvedModel = isLinked && baseAgent
? config.overrides?.model ?? baseAgent.model
: config.model;
const model = findModel(resolvedModel, availableModels);
function setConfig(next: AgentNodeConfig) {
onNodeConfigChange(node.id, next);
}
function patchConfig(patch: Partial<AgentNodeConfig>) {
onNodeConfigChange(node.id, { ...config, ...patch });
setConfig({ ...config, ...patch });
}
function patchOverride<K extends keyof WorkflowAgentOverrides>(
field: K,
value: WorkflowAgentOverrides[K],
) {
if (!baseAgent) return;
const baseValue = baseAgent[field];
if (value === baseValue) {
// Value matches base — remove the override
resetOverride(field);
} else {
setConfig({
...config,
overrides: { ...config.overrides, [field]: value },
});
}
}
function resetOverride(field: keyof WorkflowAgentOverrides) {
if (!config.overrides) return;
const { [field]: _, ...rest } = config.overrides;
const cleaned = Object.keys(rest).length > 0 ? rest : undefined;
setConfig({ ...config, overrides: cleaned });
}
function handleSelectWorkspaceAgent(agentId: string) {
const agent = workspaceAgents.find((a) => a.id === agentId);
if (!agent) return;
setConfig({
...config,
workspaceAgentId: agentId,
name: agent.name,
description: agent.description,
instructions: agent.instructions,
model: agent.model,
reasoningEffort: agent.reasoningEffort,
copilot: agent.copilot,
overrides: undefined,
});
onNodeChange(node.id, { label: agent.name });
}
function handleDetach() {
if (!baseAgent) return;
const overrides = config.overrides ?? {};
setConfig({
...config,
workspaceAgentId: undefined,
overrides: undefined,
name: overrides.name ?? baseAgent.name,
description: overrides.description ?? baseAgent.description,
instructions: overrides.instructions ?? baseAgent.instructions,
model: overrides.model ?? baseAgent.model,
reasoningEffort: overrides.reasoningEffort ?? baseAgent.reasoningEffort,
});
}
function handleSwitchToLinked() {
if (workspaceAgents.length === 0) return;
handleSelectWorkspaceAgent(workspaceAgents[0].id);
}
const displayName = isLinked && baseAgent
? config.overrides?.name ?? baseAgent.name
: config.name;
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-lg bg-[var(--color-surface-2)]">
<Bot className="size-4 text-[var(--color-text-primary)]" />
</div>
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
{config.name || 'Unnamed Agent'}
{displayName || 'Unnamed Agent'}
</div>
</div>
<button
@@ -119,56 +265,229 @@ function AgentNodeInspector({
</button>
</div>
{/* Source selector */}
<div className="space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Source</span>
<div className="flex gap-1">
<button
className={`flex flex-1 items-center justify-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[12px] font-medium transition-all ${
!isLinked
? 'bg-[var(--color-accent)]/15 text-[var(--color-accent)] ring-1 ring-[var(--color-accent)]/25'
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => { if (isLinked) handleDetach(); }}
type="button"
>
Inline
</button>
<button
className={`flex flex-1 items-center justify-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[12px] font-medium transition-all ${
isLinked
? 'bg-[var(--color-accent)]/15 text-[var(--color-accent)] ring-1 ring-[var(--color-accent)]/25'
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-secondary)]'
}`}
onClick={() => { if (!isLinked) handleSwitchToLinked(); }}
type="button"
>
<Link2 className="size-3" />
Saved Agent
</button>
</div>
</div>
{/* Workspace agent picker (linked mode) */}
{isLinked && (
<div className="space-y-1.5">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Agent</span>
{workspaceAgents.length > 0 ? (
<select
className={selectClass}
onChange={(e) => handleSelectWorkspaceAgent(e.target.value)}
value={config.workspaceAgentId ?? ''}
>
{isStale && (
<option disabled value={config.workspaceAgentId}>
(Deleted agent)
</option>
)}
{workspaceAgents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
) : (
<p className="rounded-lg bg-[var(--color-surface-2)] px-3 py-2 text-[12px] text-[var(--color-text-muted)]">
No saved agents. Create agents in Settings Agents.
</p>
)}
{isStale && (
<div className="flex items-start gap-1.5 rounded-lg bg-[var(--color-status-warning)]/10 px-2.5 py-1.5 text-[11px] text-[var(--color-status-warning)]">
<AlertCircle className="mt-0.5 size-3 shrink-0" />
<span>The linked agent was deleted. Select another or switch to inline.</span>
</div>
)}
{baseAgent && (
<button
className="flex items-center gap-1.5 text-[11px] text-[var(--color-text-muted)] transition hover:text-[var(--color-text-secondary)]"
onClick={handleDetach}
title="Detach from saved agent and use inline configuration"
type="button"
>
<Unlink className="size-3" />
Detach to inline
</button>
)}
</div>
)}
{/* Label field (always shown) */}
<InputField
label="Label"
onChange={(v) => onNodeChange(node.id, { label: v })}
placeholder="Display label"
value={node.label}
/>
<InputField
label="Agent Name"
onChange={(v) => patchConfig({ name: v })}
placeholder="Agent name"
value={config.name}
/>
<InputField
label="Description"
onChange={(v) => patchConfig({ description: v })}
placeholder="What this agent does"
value={config.description}
/>
<InputField
label="Instructions"
multiline
onChange={(v) => patchConfig({ instructions: v })}
placeholder="System instructions for this agent"
value={config.instructions}
/>
<div className="space-y-3">
<ModelSelect
models={availableModels}
onChange={(value) => {
const m = findModel(value, availableModels);
patchConfig({
model: value,
reasoningEffort: resolveReasoningEffort(m, config.reasoningEffort),
});
}}
value={config.model}
/>
<div className="space-y-1.5">
<ReasoningEffortSelect
label="Reasoning"
onChange={(value) => patchConfig({ reasoningEffort: value })}
supportedEfforts={getSupportedReasoningEfforts(model)}
value={config.reasoningEffort}
{/* Inline mode — direct editing */}
{!isLinked && (
<>
<InputField
label="Agent Name"
onChange={(v) => patchConfig({ name: v })}
placeholder="Agent name"
value={config.name}
/>
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Controls how much the model reasons before responding. Higher values produce more careful, thorough answers.
</p>
</div>
</div>
<InputField
label="Description"
onChange={(v) => patchConfig({ description: v })}
placeholder="What this agent does"
value={config.description}
/>
<InputField
label="Instructions"
multiline
onChange={(v) => patchConfig({ instructions: v })}
placeholder="System instructions for this agent"
value={config.instructions}
/>
<div className="space-y-3">
<ModelSelect
models={availableModels}
onChange={(value) => {
const m = findModel(value, availableModels);
patchConfig({
model: value,
reasoningEffort: resolveReasoningEffort(m, config.reasoningEffort),
});
}}
value={config.model}
/>
<div className="space-y-1.5">
<ReasoningEffortSelect
label="Reasoning"
onChange={(value) => patchConfig({ reasoningEffort: value })}
supportedEfforts={getSupportedReasoningEfforts(model)}
value={config.reasoningEffort}
/>
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Controls how much the model reasons before responding. Higher values produce more careful, thorough answers.
</p>
</div>
</div>
</>
)}
{/* Linked mode — overridable fields */}
{isLinked && baseAgent && (
<>
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)]/50 px-3 py-2 text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Configuration inherited from <strong className="text-[var(--color-text-secondary)]">{baseAgent.name}</strong>. Edit fields below to override per-node.
</div>
<OverridableField
baseValue={baseAgent.name}
label="Agent Name"
onChange={(v) => patchOverride('name', v)}
onReset={() => resetOverride('name')}
overrideValue={config.overrides?.name}
placeholder="Agent name"
/>
<OverridableField
baseValue={baseAgent.description}
label="Description"
onChange={(v) => patchOverride('description', v)}
onReset={() => resetOverride('description')}
overrideValue={config.overrides?.description}
placeholder="What this agent does"
/>
<OverridableField
baseValue={baseAgent.instructions}
label="Instructions"
multiline
onChange={(v) => patchOverride('instructions', v)}
onReset={() => resetOverride('instructions')}
overrideValue={config.overrides?.instructions}
placeholder="System instructions for this agent"
/>
<div className="space-y-3">
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Model</span>
{config.overrides?.model !== undefined && (
<button
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)] transition hover:bg-[var(--color-accent)]/10"
onClick={() => resetOverride('model')}
title="Reset to saved agent value"
type="button"
>
<RotateCcw className="size-2.5" />
Reset
</button>
)}
</div>
<ModelSelect
models={availableModels}
onChange={(value) => {
const m = findModel(value, availableModels);
patchOverride('model', value);
patchOverride('reasoningEffort', resolveReasoningEffort(m, config.overrides?.reasoningEffort ?? baseAgent.reasoningEffort));
}}
value={resolvedModel}
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Reasoning</span>
{config.overrides?.reasoningEffort !== undefined && (
<button
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)] transition hover:bg-[var(--color-accent)]/10"
onClick={() => resetOverride('reasoningEffort')}
title="Reset to saved agent value"
type="button"
>
<RotateCcw className="size-2.5" />
Reset
</button>
)}
</div>
<ReasoningEffortSelect
label=""
onChange={(value) => patchOverride('reasoningEffort', value)}
supportedEfforts={getSupportedReasoningEfforts(model)}
value={config.overrides?.reasoningEffort ?? baseAgent.reasoningEffort}
/>
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
Controls how much the model reasons before responding. Higher values produce more careful, thorough answers.
</p>
</div>
</div>
</>
)}
</div>
);
}
@@ -404,6 +723,7 @@ function EdgeInspector({
export function WorkflowGraphInspector({
availableModels,
workspaceAgents,
workflow,
workflows,
selectedNodeId,
@@ -464,6 +784,7 @@ export function WorkflowGraphInspector({
onNodeChange={onNodeChange}
onNodeConfigChange={onNodeConfigChange}
onNodeRemove={onNodeRemove}
workspaceAgents={workspaceAgents}
/>
</div>
);
@@ -1,9 +1,12 @@
import { Bot, FunctionSquare, GitBranch, Play, Radio, Square } from 'lucide-react';
import { Bot, FunctionSquare, GitBranch, Link2, Play, Radio, Square } from 'lucide-react';
import type { WorkflowNodeKind } from '@shared/domain/workflow';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
interface WorkflowNodePaletteProps {
onAddNode: (kind: WorkflowNodeKind) => void;
onAddWorkspaceAgentNode: (agentId: string) => void;
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>;
disabledKinds?: ReadonlySet<WorkflowNodeKind>;
}
@@ -30,7 +33,7 @@ const paletteGroups: PaletteGroup[] = [
{
label: 'Agents',
items: [
{ kind: 'agent', label: 'Agent', icon: Bot, color: 'text-[var(--color-accent)]' },
{ kind: 'agent', label: 'New Agent', icon: Bot, color: 'text-[var(--color-accent)]' },
],
},
{
@@ -48,7 +51,7 @@ const paletteGroups: PaletteGroup[] = [
},
];
export function WorkflowNodePalette({ onAddNode, disabledKinds }: WorkflowNodePaletteProps) {
export function WorkflowNodePalette({ onAddNode, onAddWorkspaceAgentNode, workspaceAgents, disabledKinds }: WorkflowNodePaletteProps) {
return (
<div className="space-y-4 p-3">
<h4 className="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
@@ -81,6 +84,25 @@ export function WorkflowNodePalette({ onAddNode, disabledKinds }: WorkflowNodePa
</button>
);
})}
{/* Saved workspace agents in the Agents group */}
{group.label === 'Agents' && workspaceAgents.length > 0 && (
<>
<div className="mx-1 my-1.5 border-t border-[var(--color-border)]" />
{workspaceAgents.map((agent) => (
<button
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[12px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
key={agent.id}
onClick={() => onAddWorkspaceAgentNode(agent.id)}
title={agent.description || agent.name}
type="button"
>
<Link2 className="size-3.5 text-[var(--color-accent)]" />
<span className="truncate">{agent.name}</span>
</button>
))}
</>
)}
</div>
</div>
))}
+31 -14
View File
@@ -1,39 +1,56 @@
import { useEffect, useState } from 'react';
function formatElapsed(startMs: number): string {
const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
/** Format a millisecond duration as a human-readable elapsed string. */
export function formatElapsedMs(durationMs: number): string {
const seconds = Math.max(0, Math.floor(durationMs / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return `${minutes}m ${remainder}s`;
return remainder > 0 ? `${minutes}m ${remainder}s` : `${minutes}m`;
}
/**
* Returns a live-ticking elapsed-time string while `active` is true.
* Freezes the display when `active` becomes false.
*
* When the timer is no longer active and `completedAt` is provided, the
* duration is computed as `completedAt startedAt` — giving an accurate
* frozen value even when the session is reopened much later. Without
* `completedAt` the hook falls back to `Date.now() startedAt` at the
* moment `active` becomes false.
*/
export function useElapsedTimer(startedAt: string | undefined, active: boolean): string | undefined {
export function useElapsedTimer(
startedAt: string | undefined,
active: boolean,
completedAt?: string,
): string | undefined {
const startMs = startedAt ? new Date(startedAt).getTime() : undefined;
const endMs = completedAt ? new Date(completedAt).getTime() : undefined;
const [elapsed, setElapsed] = useState<string | undefined>(() => {
if (!startMs) return undefined;
return formatElapsed(startMs);
});
const computeElapsed = (): string | undefined => {
if (!startMs || Number.isNaN(startMs)) return undefined;
if (!active && endMs && !Number.isNaN(endMs)) {
return formatElapsedMs(endMs - startMs);
}
return formatElapsedMs(Date.now() - startMs);
};
const [elapsed, setElapsed] = useState<string | undefined>(computeElapsed);
useEffect(() => {
if (!startMs) {
if (!startMs || Number.isNaN(startMs)) {
setElapsed(undefined);
return;
}
// Always sync to current value immediately
setElapsed(formatElapsed(startMs));
// Sync immediately
setElapsed(computeElapsed());
if (!active) return;
const id = setInterval(() => setElapsed(formatElapsed(startMs)), 1000);
const id = setInterval(() => setElapsed(formatElapsedMs(Date.now() - startMs)), 1000);
return () => clearInterval(id);
}, [startMs, active]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [startMs, endMs, active]);
return elapsed;
}
+104
View File
@@ -0,0 +1,104 @@
import { useCallback, useRef, useState } from 'react';
export interface SessionSelectionState {
/** Currently selected session IDs */
selectedIds: Set<string>;
/** Whether multi-select mode is active */
isSelecting: boolean;
/** Toggle a single session's selection */
toggle: (sessionId: string) => void;
/** Shift+click range selection: selects everything between the last anchor and the target */
rangeSelect: (sessionId: string, allVisibleIds: string[]) => void;
/** Select all provided session IDs */
selectAll: (sessionIds: string[]) => void;
/** Deselect all */
deselectAll: () => void;
/** Enter multi-select mode with an initial selection */
enterSelectionMode: (initialId: string) => void;
/** Exit multi-select mode and clear selection */
exitSelectionMode: () => void;
/** Check if a session is selected */
isSelected: (sessionId: string) => boolean;
}
export function useSessionSelection(): SessionSelectionState {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [isSelecting, setIsSelecting] = useState(false);
// Tracks the last explicitly toggled session for shift+click range behaviour
const anchorRef = useRef<string | null>(null);
const enterSelectionMode = useCallback((initialId: string) => {
setIsSelecting(true);
setSelectedIds(new Set([initialId]));
anchorRef.current = initialId;
}, []);
const exitSelectionMode = useCallback(() => {
setIsSelecting(false);
setSelectedIds(new Set());
anchorRef.current = null;
}, []);
const toggle = useCallback((sessionId: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(sessionId)) {
next.delete(sessionId);
} else {
next.add(sessionId);
}
anchorRef.current = sessionId;
return next;
});
}, []);
const rangeSelect = useCallback((sessionId: string, allVisibleIds: string[]) => {
const anchor = anchorRef.current;
if (!anchor) {
// No anchor yet — treat as a simple toggle
setSelectedIds(new Set([sessionId]));
anchorRef.current = sessionId;
return;
}
const anchorIdx = allVisibleIds.indexOf(anchor);
const targetIdx = allVisibleIds.indexOf(sessionId);
if (anchorIdx === -1 || targetIdx === -1) return;
const start = Math.min(anchorIdx, targetIdx);
const end = Math.max(anchorIdx, targetIdx);
const rangeIds = allVisibleIds.slice(start, end + 1);
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of rangeIds) {
next.add(id);
}
return next;
});
// Anchor stays the same for contiguous range extension
}, []);
const selectAll = useCallback((sessionIds: string[]) => {
setSelectedIds(new Set(sessionIds));
}, []);
const deselectAll = useCallback(() => {
setSelectedIds(new Set());
}, []);
const isSelected = useCallback((sessionId: string) => selectedIds.has(sessionId), [selectedIds]);
return {
selectedIds,
isSelecting,
toggle,
rangeSelect,
selectAll,
deselectAll,
enterSelectionMode,
exitSelectionMode,
isSelected,
};
}
+1 -1
View File
@@ -23,7 +23,7 @@ function CodeBlock({ language, children }: { language: string; children: string
}
return (
<div className="group relative my-3 overflow-hidden rounded-lg border border-zinc-800 bg-[#0d0d10]">
<div className="group relative my-3 overflow-hidden rounded-lg border border-zinc-800 bg-surface-1">
<div className="flex items-center justify-between border-b border-zinc-800/80 px-4 py-1.5">
<span className="select-none text-[11px] text-zinc-500">
{language || 'text'}
+129 -1
View File
@@ -674,6 +674,125 @@ body {
animation: intent-divider-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* Timeline spine connecting activity items */
.activity-timeline-spine > .turn-activity-row:not([role="separator"]) {
position: relative;
}
.activity-timeline-spine > .turn-activity-row:not([role="separator"]):not(:last-child)::before {
content: "";
position: absolute;
left: 7px; /* center of the 16px (w-4) icon column */
top: 22px; /* below the icon center */
bottom: 0;
width: 1px;
background: var(--color-border);
opacity: 0.25;
pointer-events: none;
}
/* ── Batch selection animations ──────────────────────────────── */
@keyframes selection-checkbox-in {
from {
opacity: 0;
transform: scale(0);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes selection-checkbox-out {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0);
}
}
.selection-checkbox-enter {
animation: selection-checkbox-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.selection-checkbox-exit {
animation: selection-checkbox-out 0.15s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes checkbox-check {
0% {
transform: scale(1);
}
40% {
transform: scale(1.2);
}
100% {
transform: scale(1);
}
}
.checkbox-check {
animation: checkbox-check 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes batch-action-bar-in {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.batch-action-bar-enter {
animation: batch-action-bar-in 0.25s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes undo-toast-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes undo-toast-out {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(8px) scale(0.96);
}
}
.undo-toast-enter {
animation: undo-toast-in 0.25s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.undo-toast-exit {
animation: undo-toast-out 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes toast-progress {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}
/* ── Respect reduced motion ──────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
@@ -697,6 +816,15 @@ body {
animation: none;
}
.selection-checkbox-enter,
.selection-checkbox-exit,
.checkbox-check,
.batch-action-bar-enter,
.undo-toast-enter,
.undo-toast-exit {
animation: none;
}
.qp-border-streaming {
animation: none;
border-color: var(--color-accent);
@@ -762,7 +890,7 @@ html:has(body[data-quickprompt]) {
}
.qp-panel-enter {
animation: qp-panel-in 0.2s cubic-bezier(0.16, 1, 0.3, 1);
animation: qp-panel-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* Response area entrance */
+3
View File
@@ -25,6 +25,7 @@ export const ipcChannels = {
setNotificationsEnabled: 'settings:set-notifications-enabled',
setMinimizeToTray: 'settings:set-minimize-to-tray',
setGitAutoRefreshEnabled: 'settings:set-git-auto-refresh-enabled',
setOpenTelemetry: 'settings:set-opentelemetry',
checkForUpdates: 'app:check-for-updates',
installUpdate: 'app:install-update',
saveMcpServer: 'tooling:mcp:save',
@@ -49,6 +50,8 @@ export const ipcChannels = {
setSessionPinned: 'sessions:set-pinned',
setSessionArchived: 'sessions:set-archived',
deleteSession: 'sessions:delete',
batchSetSessionsArchived: 'sessions:batch-set-archived',
batchDeleteSessions: 'sessions:batch-delete',
regenerateSessionMessage: 'sessions:regenerate-message',
editAndResendSessionMessage: 'sessions:edit-and-resend-message',
sendSessionMessage: 'sessions:send-message',
+13
View File
@@ -20,6 +20,7 @@ import type {
SessionToolingSelection,
AppearanceTheme,
QuickPromptSettings,
OpenTelemetrySettings,
} from '@shared/domain/tooling';
import type { WorkspaceState } from '@shared/domain/workspace';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
@@ -218,6 +219,15 @@ export interface DeleteSessionInput {
sessionId: string;
}
export interface BatchSetSessionsArchivedInput {
sessionIds: string[];
isArchived: boolean;
}
export interface BatchDeleteSessionsInput {
sessionIds: string[];
}
export interface ResizeTerminalInput {
cols: number;
rows: number;
@@ -334,6 +344,8 @@ export interface ElectronApi {
setSessionPinned(input: SetSessionPinnedInput): Promise<WorkspaceState>;
setSessionArchived(input: SetSessionArchivedInput): Promise<WorkspaceState>;
deleteSession(input: DeleteSessionInput): Promise<WorkspaceState>;
batchSetSessionsArchived(input: BatchSetSessionsArchivedInput): Promise<WorkspaceState>;
batchDeleteSessions(input: BatchDeleteSessionsInput): Promise<WorkspaceState>;
regenerateSessionMessage(input: RegenerateSessionMessageInput): Promise<void>;
editAndResendSessionMessage(input: EditAndResendSessionMessageInput): Promise<void>;
sendSessionMessage(input: SendSessionMessageInput): Promise<void>;
@@ -353,6 +365,7 @@ export interface ElectronApi {
setNotificationsEnabled(enabled: boolean): Promise<WorkspaceState>;
setMinimizeToTray(enabled: boolean): Promise<WorkspaceState>;
setGitAutoRefreshEnabled(enabled: boolean): Promise<WorkspaceState>;
setOpenTelemetry(settings: OpenTelemetrySettings): Promise<WorkspaceState>;
checkForUpdates(): Promise<UpdateStatus>;
installUpdate(): Promise<void>;
describeTerminal(): Promise<TerminalSnapshot | undefined>;
+18
View File
@@ -68,6 +68,13 @@ export interface QuickPromptSettings {
defaultReasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh';
}
export interface OpenTelemetrySettings {
enabled: boolean;
endpoint: string;
}
export const DEFAULT_OTEL_ENDPOINT = 'http://localhost:4317';
export function createDefaultQuickPromptSettings(): QuickPromptSettings {
return {
enabled: true,
@@ -85,6 +92,7 @@ export interface WorkspaceSettings {
minimizeToTray?: boolean;
gitAutoRefreshEnabled?: boolean;
quickPrompt?: QuickPromptSettings;
openTelemetry?: OpenTelemetrySettings;
}
export interface SessionToolingSelection {
@@ -231,6 +239,16 @@ export function normalizeWorkspaceSettings(settings?: Partial<WorkspaceSettings>
...(settings?.minimizeToTray !== undefined ? { minimizeToTray: settings.minimizeToTray } : {}),
...(settings?.gitAutoRefreshEnabled !== undefined ? { gitAutoRefreshEnabled: settings.gitAutoRefreshEnabled } : {}),
...(settings?.quickPrompt !== undefined ? { quickPrompt: settings.quickPrompt } : {}),
...(settings?.openTelemetry !== undefined ? { openTelemetry: normalizeOpenTelemetrySettings(settings.openTelemetry) } : {}),
};
}
export function normalizeOpenTelemetrySettings(settings?: Partial<OpenTelemetrySettings>): OpenTelemetrySettings {
return {
enabled: settings?.enabled === true,
endpoint: typeof settings?.endpoint === 'string' && settings.endpoint.trim() !== ''
? settings.endpoint.trim()
: DEFAULT_OTEL_ENDPOINT,
};
}
+1 -47
View File
@@ -5,52 +5,6 @@ function tokenize(value: string): string[] {
.filter((token) => token.length > 0);
}
function shouldInsertNewlineBoundary(current: string, incoming: string): boolean {
if (current.endsWith('\n')) {
return false;
}
return /^(?:#{1,6}\s|[-*+]\s|\d+\.\s|>\s|```)/.test(incoming.trimStart());
}
function shouldInsertSpaceBoundary(current: string, incoming: string): boolean {
const lastChar = current.at(-1);
const firstChar = incoming[0];
if (!lastChar || !firstChar) {
return false;
}
if (/\s/.test(lastChar) || /\s/.test(firstChar) || /[([{/"'`]/.test(lastChar)) {
return false;
}
if (/^[.,!?;:%)\]}]/.test(firstChar)) {
return false;
}
if (/^[*_`~\[]/.test(firstChar) || /^[A-Z0-9]/.test(firstChar)) {
return true;
}
const currentTokens = tokenize(current);
const incomingTokens = tokenize(incoming);
const firstIncomingToken = incomingTokens[0] ?? '';
return currentTokens.length >= 2 && incomingTokens.length >= 2 && firstIncomingToken.length >= 2;
}
function appendWithNaturalBoundary(current: string, incoming: string): string {
if (shouldInsertNewlineBoundary(current, incoming)) {
return `${current}\n${incoming}`;
}
if (shouldInsertSpaceBoundary(current, incoming)) {
return `${current} ${incoming}`;
}
return current + incoming;
}
function computeSuffixPrefixOverlap(current: string, incoming: string): number {
const maxOverlap = Math.min(current.length, incoming.length);
for (let length = maxOverlap; length > 0; length -= 1) {
@@ -109,5 +63,5 @@ export function mergeStreamingText(current: string, incoming: string): string {
return incoming;
}
return appendWithNaturalBoundary(current, incoming);
return current + incoming;
}
+42
View File
@@ -37,4 +37,46 @@ describe('createSidecarEnvironment', () => {
HTTPS_PROXY: 'http://proxy.local:8080',
});
});
test('injects OTEL_EXPORTER_OTLP_ENDPOINT when OpenTelemetry is enabled', () => {
expect(
createSidecarEnvironment(
{ PATH: 'C:\\tools' },
{ enabled: true, endpoint: 'http://localhost:4317' },
),
).toEqual({
PATH: 'C:\\tools',
OTEL_EXPORTER_OTLP_ENDPOINT: 'http://localhost:4317',
});
});
test('does not inject OTEL_EXPORTER_OTLP_ENDPOINT when OpenTelemetry is disabled', () => {
expect(
createSidecarEnvironment(
{ PATH: 'C:\\tools' },
{ enabled: false, endpoint: 'http://localhost:4317' },
),
).toEqual({
PATH: 'C:\\tools',
});
});
test('does not inject OTEL_EXPORTER_OTLP_ENDPOINT when settings are undefined', () => {
expect(
createSidecarEnvironment({ PATH: 'C:\\tools' }),
).toEqual({
PATH: 'C:\\tools',
});
});
test('does not inject OTEL_EXPORTER_OTLP_ENDPOINT when endpoint is empty', () => {
expect(
createSidecarEnvironment(
{ PATH: 'C:\\tools' },
{ enabled: true, endpoint: '' },
),
).toEqual({
PATH: 'C:\\tools',
});
});
});
+187
View File
@@ -0,0 +1,187 @@
import { describe, expect, test } from 'bun:test';
/**
* Test the core selection logic extracted from useSessionSelection.
* Since the hook is thin state wrapper, we test the pure logic operations directly.
*/
describe('session selection logic', () => {
// Helpers that mirror the hook's internal logic
function toggle(selected: Set<string>, sessionId: string): Set<string> {
const next = new Set(selected);
if (next.has(sessionId)) next.delete(sessionId);
else next.add(sessionId);
return next;
}
function rangeSelect(
selected: Set<string>,
anchor: string | null,
target: string,
allVisibleIds: string[],
): Set<string> {
if (!anchor) return new Set([target]);
const anchorIdx = allVisibleIds.indexOf(anchor);
const targetIdx = allVisibleIds.indexOf(target);
if (anchorIdx === -1 || targetIdx === -1) return selected;
const start = Math.min(anchorIdx, targetIdx);
const end = Math.max(anchorIdx, targetIdx);
const next = new Set(selected);
for (const id of allVisibleIds.slice(start, end + 1)) next.add(id);
return next;
}
function selectAll(sessionIds: string[]): Set<string> {
return new Set(sessionIds);
}
function filterSelectable(sessions: { id: string; status: string }[]): string[] {
return sessions.filter((s) => s.status !== 'running').map((s) => s.id);
}
describe('toggle', () => {
test('adds a session to empty selection', () => {
const result = toggle(new Set(), 'a');
expect(result.has('a')).toBe(true);
expect(result.size).toBe(1);
});
test('removes an already-selected session', () => {
const result = toggle(new Set(['a', 'b']), 'a');
expect(result.has('a')).toBe(false);
expect(result.has('b')).toBe(true);
expect(result.size).toBe(1);
});
test('adds to existing selection', () => {
const result = toggle(new Set(['a']), 'b');
expect(result.has('a')).toBe(true);
expect(result.has('b')).toBe(true);
expect(result.size).toBe(2);
});
});
describe('rangeSelect', () => {
const visible = ['a', 'b', 'c', 'd', 'e'];
test('selects range from anchor to target (forward)', () => {
const result = rangeSelect(new Set(['b']), 'b', 'd', visible);
expect([...result].sort()).toEqual(['b', 'c', 'd']);
});
test('selects range from anchor to target (backward)', () => {
const result = rangeSelect(new Set(['d']), 'd', 'b', visible);
expect([...result].sort()).toEqual(['b', 'c', 'd']);
});
test('preserves existing selections outside the range', () => {
const result = rangeSelect(new Set(['a', 'c']), 'c', 'e', visible);
expect([...result].sort()).toEqual(['a', 'c', 'd', 'e']);
});
test('falls back to single selection when no anchor', () => {
const result = rangeSelect(new Set(), null, 'c', visible);
expect([...result]).toEqual(['c']);
});
test('no-ops when anchor is not in visible list', () => {
const result = rangeSelect(new Set(['x']), 'x', 'c', visible);
expect([...result]).toEqual(['x']);
});
test('no-ops when target is not in visible list', () => {
const result = rangeSelect(new Set(['a']), 'a', 'z', visible);
expect([...result]).toEqual(['a']);
});
});
describe('selectAll', () => {
test('selects all provided IDs', () => {
const result = selectAll(['a', 'b', 'c']);
expect(result.size).toBe(3);
expect(result.has('a')).toBe(true);
expect(result.has('b')).toBe(true);
expect(result.has('c')).toBe(true);
});
test('handles empty list', () => {
const result = selectAll([]);
expect(result.size).toBe(0);
});
});
describe('filterSelectable (running session exclusion)', () => {
test('excludes running sessions', () => {
const sessions = [
{ id: 'a', status: 'idle' },
{ id: 'b', status: 'running' },
{ id: 'c', status: 'error' },
{ id: 'd', status: 'running' },
];
const result = filterSelectable(sessions);
expect(result).toEqual(['a', 'c']);
});
test('includes all when none are running', () => {
const sessions = [
{ id: 'a', status: 'idle' },
{ id: 'b', status: 'idle' },
];
const result = filterSelectable(sessions);
expect(result).toEqual(['a', 'b']);
});
test('returns empty when all are running', () => {
const sessions = [
{ id: 'a', status: 'running' },
{ id: 'b', status: 'running' },
];
const result = filterSelectable(sessions);
expect(result).toEqual([]);
});
});
describe('enter and exit selection mode', () => {
test('entering with an initial ID creates a set with that ID', () => {
const selected = new Set(['initial']);
expect(selected.size).toBe(1);
expect(selected.has('initial')).toBe(true);
});
test('exiting clears all selections', () => {
const selected = new Set(['a', 'b', 'c']);
const cleared = new Set<string>();
expect(cleared.size).toBe(0);
expect(selected.size).toBe(3);
});
});
describe('batch archive determination', () => {
test('allSelectedArchived is true when all selected are archived', () => {
const sessions = [
{ id: 'a', isArchived: true },
{ id: 'b', isArchived: true },
{ id: 'c', isArchived: false },
];
const selected = new Set(['a', 'b']);
const allArchived = [...selected].every((id) => {
const s = sessions.find((s) => s.id === id);
return s?.isArchived;
});
expect(allArchived).toBe(true);
});
test('allSelectedArchived is false when any selected is not archived', () => {
const sessions = [
{ id: 'a', isArchived: true },
{ id: 'b', isArchived: false },
];
const selected = new Set(['a', 'b']);
const allArchived = [...selected].every((id) => {
const s = sessions.find((s) => s.id === id);
return s?.isArchived;
});
expect(allArchived).toBe(false);
});
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, test } from 'bun:test';
import { formatElapsedMs } from '@renderer/hooks/useElapsedTimer';
describe('formatElapsedMs', () => {
test('formats sub-second durations as 0s', () => {
expect(formatElapsedMs(0)).toBe('0s');
expect(formatElapsedMs(500)).toBe('0s');
expect(formatElapsedMs(999)).toBe('0s');
});
test('formats seconds under a minute', () => {
expect(formatElapsedMs(1000)).toBe('1s');
expect(formatElapsedMs(5_000)).toBe('5s');
expect(formatElapsedMs(59_000)).toBe('59s');
});
test('formats minutes with remaining seconds', () => {
expect(formatElapsedMs(60_000)).toBe('1m');
expect(formatElapsedMs(90_000)).toBe('1m 30s');
expect(formatElapsedMs(125_000)).toBe('2m 5s');
});
test('formats exact minutes without trailing seconds', () => {
expect(formatElapsedMs(120_000)).toBe('2m');
expect(formatElapsedMs(300_000)).toBe('5m');
});
test('clamps negative durations to 0s', () => {
expect(formatElapsedMs(-5000)).toBe('0s');
});
test('formats large durations correctly', () => {
// 2 hours, 30 minutes, 15 seconds = 9015s = 150m 15s
expect(formatElapsedMs(9_015_000)).toBe('150m 15s');
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, test } from 'bun:test';
import {
aspireDashboardContainerName,
aspireDashboardImage,
aspireDashboardUrls,
createAspireDashboardRunArgs,
createOpenTelemetryEnvironment,
} from '../../scripts/aspireDashboard';
describe('aspire dashboard helpers', () => {
test('creates the official standalone Docker command with mapped OTLP ports', () => {
expect(createAspireDashboardRunArgs()).toEqual([
'run',
'--rm',
'-d',
'--name',
aspireDashboardContainerName,
'-p',
'18888:18888',
'-p',
'4317:18889',
'-p',
'4318:18890',
'-e',
'ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true',
aspireDashboardImage,
]);
});
test('injects OTLP exporter settings for local Aspire development', () => {
expect(
createOpenTelemetryEnvironment(
{
PATH: 'C:\\tools',
},
aspireDashboardUrls.otlpGrpc,
),
).toEqual({
PATH: 'C:\\tools',
OTEL_EXPORTER_OTLP_ENDPOINT: aspireDashboardUrls.otlpGrpc,
OTEL_EXPORTER_OTLP_PROTOCOL: 'grpc',
});
});
});
+226
View File
@@ -0,0 +1,226 @@
import { join, resolve } from 'node:path';
import { describe, expect, test } from 'bun:test';
import {
incrementVersion,
parseReleaseBump,
runReleaseWorkflow,
type GitCommandResult,
type ReleaseDependencies,
} from '../../scripts/release';
const testRepositoryRoot = resolve('/test-workspace/aryx');
const testPackageJsonPath = join(testRepositoryRoot, 'package.json');
function gitSuccess(stdout = '', stderr = ''): GitCommandResult {
return {
exitCode: 0,
stdout,
stderr,
};
}
function gitFailure(exitCode = 1, stdout = '', stderr = ''): GitCommandResult {
return {
exitCode,
stdout,
stderr,
};
}
function createReleaseDependencies(options?: {
packageJsonText?: string;
gitResults?: GitCommandResult[];
}) {
let packageJsonText =
options?.packageJsonText ??
`{
"name": "aryx",
"version": "0.0.26"
}
`;
const gitResults = [...(options?.gitResults ?? [])];
const commands: string[][] = [];
const writes: Array<{ path: string; content: string }> = [];
const logs: string[] = [];
const dependencies: ReleaseDependencies = {
readText: async () => packageJsonText,
writeText: async (path, content) => {
packageJsonText = content;
writes.push({ path, content });
},
runGit: async (args) => {
commands.push(args);
const result = gitResults.shift();
if (!result) {
throw new Error(`Unexpected git command: ${args.join(' ')}`);
}
return result;
},
log: (message) => {
logs.push(message);
},
};
return {
dependencies,
commands,
writes,
logs,
getPackageJsonText: () => packageJsonText,
};
}
describe('release bump parsing', () => {
test('defaults to patch when no bump argument is provided', () => {
expect(parseReleaseBump()).toBe('patch');
});
test('rejects unsupported bump arguments', () => {
expect(() => parseReleaseBump('prerelease')).toThrow(
'Unsupported release bump "prerelease". Use patch, minor, or major.',
);
});
});
describe('version incrementing', () => {
test('supports patch, minor, and major bumps', () => {
expect(incrementVersion('0.0.26', 'patch')).toBe('0.0.27');
expect(incrementVersion('0.0.26', 'minor')).toBe('0.1.0');
expect(incrementVersion('0.0.26', 'major')).toBe('1.0.0');
});
});
describe('release workflow', () => {
test('bumps package.json, commits, tags, and pushes the tracked branch', async () => {
const { dependencies, commands, writes, logs, getPackageJsonText } = createReleaseDependencies({
gitResults: [
gitSuccess(),
gitSuccess('main\n'),
gitSuccess('origin\n'),
gitSuccess('refs/heads/main\n'),
gitSuccess(),
gitSuccess(),
gitSuccess(),
gitSuccess(),
gitSuccess(),
],
});
const result = await runReleaseWorkflow(
{
repositoryRoot: testRepositoryRoot,
packageJsonPath: testPackageJsonPath,
},
dependencies,
);
expect(result).toMatchObject({
bump: 'patch',
currentVersion: '0.0.26',
nextVersion: '0.0.27',
tagName: 'v0.0.27',
localBranch: 'main',
remote: 'origin',
upstreamBranch: 'main',
});
expect(writes).toHaveLength(1);
expect(getPackageJsonText()).toContain('"version": "0.0.27"');
expect(commands).toEqual([
['status', '--porcelain'],
['symbolic-ref', '--quiet', '--short', 'HEAD'],
['config', '--get', 'branch.main.remote'],
['config', '--get', 'branch.main.merge'],
['tag', '--list', 'v0.0.27'],
['add', 'package.json'],
['commit', '-m', 'chore: release v0.0.27'],
['tag', '-a', 'v0.0.27', '-m', 'Release v0.0.27'],
['push', '--follow-tags', 'origin', 'HEAD:main'],
]);
expect(logs).toEqual(['Released v0.0.27 from main to origin/main.']);
});
test('fails before writing package.json when the worktree is dirty', async () => {
const { dependencies, commands, writes } = createReleaseDependencies({
gitResults: [gitSuccess(' M README.md\n')],
});
await expect(
runReleaseWorkflow(
{
repositoryRoot: testRepositoryRoot,
packageJsonPath: testPackageJsonPath,
},
dependencies,
),
).rejects.toThrow('Release requires a clean git worktree. Commit, stash, or discard changes first.');
expect(writes).toHaveLength(0);
expect(commands).toEqual([['status', '--porcelain']]);
});
test('fails before writing package.json when the next release tag already exists', async () => {
const { dependencies, commands, writes } = createReleaseDependencies({
gitResults: [
gitSuccess(),
gitSuccess('main\n'),
gitSuccess('origin\n'),
gitSuccess('refs/heads/main\n'),
gitSuccess('v0.1.0\n'),
],
});
await expect(
runReleaseWorkflow(
{
repositoryRoot: testRepositoryRoot,
packageJsonPath: testPackageJsonPath,
bumpArg: 'minor',
},
dependencies,
),
).rejects.toThrow('Tag v0.1.0 already exists. Choose a different release version.');
expect(writes).toHaveLength(0);
expect(commands).toEqual([
['status', '--porcelain'],
['symbolic-ref', '--quiet', '--short', 'HEAD'],
['config', '--get', 'branch.main.remote'],
['config', '--get', 'branch.main.merge'],
['tag', '--list', 'v0.1.0'],
]);
});
test('fails when the current branch does not have an upstream', async () => {
const { dependencies, commands, writes } = createReleaseDependencies({
gitResults: [
gitSuccess(),
gitSuccess('release\n'),
gitFailure(),
gitFailure(),
],
});
await expect(
runReleaseWorkflow(
{
repositoryRoot: testRepositoryRoot,
packageJsonPath: testPackageJsonPath,
},
dependencies,
),
).rejects.toThrow('Release requires an upstream branch for release. Configure tracking before running the release helper.');
expect(writes).toHaveLength(0);
expect(commands).toEqual([
['status', '--porcelain'],
['symbolic-ref', '--quiet', '--short', 'HEAD'],
['config', '--get', 'branch.release.remote'],
['config', '--get', 'branch.release.merge'],
]);
});
});
+22 -5
View File
@@ -26,17 +26,34 @@ describe('streaming text merge', () => {
expect(mergeStreamingText(current, incoming)).toBe(incoming);
});
test('inserts whitespace when snapshot-like updates would otherwise glue words together', () => {
expect(mergeStreamingText('How about', 'The **Ashen Crown** feels')).toBe(
test.each([
['requires all wr', 'itable fields', 'requires all writable fields'],
['becomes frag', 'ile for clients', 'becomes fragile for clients'],
['Endpoint (domain) uniqu', 'eness across tenants', 'Endpoint (domain) uniqueness across tenants'],
['The doc says "wildc', 'ards are allowed"', 'The doc says "wildcards are allowed"'],
[
'What wildcard syntax supported (*.cont',
'oso.com? contoso.* ?)',
'What wildcard syntax supported (*.contoso.com? contoso.* ?)',
],
['How does Pur', 'view match traffic', 'How does Purview match traffic'],
['more M', 'DA properties', 'more MDA properties'],
['does UA', 'G normalize them?', 'does UAG normalize them?'],
])('does not inject spaces into split words: %s + %s', (current, incoming, expected) => {
expect(mergeStreamingText(current, incoming)).toBe(expected);
});
test('preserves whitespace already present in delta', () => {
expect(mergeStreamingText('How about', ' The **Ashen Crown** feels')).toBe(
'How about The **Ashen Crown** feels',
);
expect(mergeStreamingText('The **Ashen Crown** feels', 'classic and timeless.')).toBe(
expect(mergeStreamingText('The **Ashen Crown** feels', ' classic and timeless.')).toBe(
'The **Ashen Crown** feels classic and timeless.',
);
});
test('inserts a newline before streamed markdown block markers', () => {
expect(mergeStreamingText('If you want, I can also give you', '- darker titles')).toBe(
test('preserves newline already present in delta', () => {
expect(mergeStreamingText('If you want, I can also give you', '\n- darker titles')).toBe(
'If you want, I can also give you\n- darker titles',
);
});
+36
View File
@@ -6,12 +6,14 @@ import {
groupApprovalToolsByProvider,
listApprovalToolDefinitions,
listApprovalToolNames,
normalizeOpenTelemetrySettings,
normalizeWorkspaceSettings,
resolveProjectToolingSettings,
resolveToolLabel,
resolveWorkspaceToolingSettings,
validateLspProfileDefinition,
validateMcpServerDefinition,
DEFAULT_OTEL_ENDPOINT,
type LspProfileDefinition,
type McpServerDefinition,
type WorkspaceToolingSettings,
@@ -118,6 +120,40 @@ describe('tooling settings helpers', () => {
expect(normalizeWorkspaceSettings({ terminalHeight: Number.NaN }).terminalHeight).toBeUndefined();
});
test('normalizes OpenTelemetry settings with defaults', () => {
const otel = normalizeOpenTelemetrySettings();
expect(otel.enabled).toBe(false);
expect(otel.endpoint).toBe(DEFAULT_OTEL_ENDPOINT);
});
test('preserves valid OpenTelemetry settings', () => {
const otel = normalizeOpenTelemetrySettings({ enabled: true, endpoint: 'http://custom:4317' });
expect(otel.enabled).toBe(true);
expect(otel.endpoint).toBe('http://custom:4317');
});
test('trims whitespace from OpenTelemetry endpoint', () => {
const otel = normalizeOpenTelemetrySettings({ enabled: true, endpoint: ' http://localhost:4317 ' });
expect(otel.endpoint).toBe('http://localhost:4317');
});
test('falls back to default endpoint when blank', () => {
const otel = normalizeOpenTelemetrySettings({ enabled: true, endpoint: ' ' });
expect(otel.endpoint).toBe(DEFAULT_OTEL_ENDPOINT);
});
test('normalizeWorkspaceSettings preserves OpenTelemetry when present', () => {
const settings = normalizeWorkspaceSettings({
openTelemetry: { enabled: true, endpoint: 'http://jaeger:4317' },
});
expect(settings.openTelemetry).toEqual({ enabled: true, endpoint: 'http://jaeger:4317' });
});
test('normalizeWorkspaceSettings omits OpenTelemetry when absent', () => {
const settings = normalizeWorkspaceSettings({});
expect(settings.openTelemetry).toBeUndefined();
});
test('validates required MCP transport settings', () => {
const localServer: McpServerDefinition = {
id: 'mcp-local',