Compare commits

..
20 Commits
Author SHA1 Message Date
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
28 changed files with 2052 additions and 171 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aryx",
"version": "0.0.25",
"version": "0.0.26",
"description": "Orchestrator for Copilot-powered agent workflows across multiple projects.",
"private": true,
"main": "dist-electron/main/index.js",
@@ -10,9 +10,9 @@
<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" />
</ItemGroup>
</Project>
+44
View File
@@ -1149,6 +1149,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);
+8
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,
@@ -224,6 +226,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),
);
+2
View File
@@ -67,6 +67,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),
+6
View File
@@ -940,6 +940,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>();
+21 -21
View File
@@ -3,12 +3,11 @@ import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranc
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
import { WorkflowEditor } from '@renderer/components/WorkflowEditor';
import { ToggleSwitch } from '@renderer/components/ui';
import { HotkeyRecorder, 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';
@@ -199,6 +198,7 @@ export function SettingsPanel({
}}
workflow={editingWorkflow}
workflows={workflows}
workspaceAgents={workspaceAgents}
/>
</div>
);
@@ -1338,13 +1338,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 +1364,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 +1373,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 +1385,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 */
+2
View File
@@ -49,6 +49,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',
+11
View File
@@ -218,6 +218,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 +343,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>;
+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');
});
});