Compare commits

..

No commits in common. "633ebf4a784f00f28e86ff0c52cdc4b23f6f89a5" and "96663f7c8854fafb44f2fb826ed9a9269f072bbe" have entirely different histories.

21 changed files with 359 additions and 1039 deletions

1
.gitignore vendored
View File

@ -3,5 +3,4 @@ gadget-drone.*.log
logfetch logfetch
.gadget .gadget
gadget/
node_modules node_modules

287
CHANGE.md
View File

@ -1,287 +0,0 @@
# CHANGELOG
This document tracks all significant changes to Gadget Code, organized by date.
---
## 2026-05-15 (Thursday)
**Context Window Configuration Added**
The most recent change introduces the `numCtx` (Context Window) setting to the SESSION panel, allowing users to configure the context window size for their AI model sessions. This setting is part of a broader enhancement to the model configuration pipeline that also includes `numPredict` and `maxCompletionTokens` parameters.
### Changes:
- Added `numCtx` setting to SESSION panel
- Updated model configuration pipeline to include `numPredict`, `numCtx`, and `maxCompletionTokens`
- Modified ChatSessionView to handle new context window settings
- Updated session initialization logic to apply context window configuration
- Added Gab AI affiliate link
- Changed Ollama abort procedure
---
## 2026-05-14 (Wednesday)
**SubProcess Observability Implementation**
This week focused on implementing comprehensive observability for SubProcess operations within the DroneManager and DroneInspector components, including detailed logging, status tracking, and event emission capabilities.
### Changes:
- Implemented Phase 2: SubProcess observability in DroneManager and DroneInspector
- Added subprocess status tracking and event emission
- Updated UI to display subprocess execution details
- Fixed mode change log message formatting
---
## 2026-05-13 (Tuesday)
**Documentation and Editor Improvements**
Documentation updates include adding continuation prompts and remaining steps for User Mode, session summary documentation for MVP completion, and formatting cleanup in AGENTS.md. The editor improvements address a critical issue with CodeMirror editor height constraints in flex layouts.
### Changes:
- Added continuation prompt and remaining steps for User Mode documentation
- Added session summary for User Mode MVP completion
- Fixed CodeMirror editor height constraint in flex layout
- Minor formatting cleanup in AGENTS.md
- Created new files and prep work for landing page project
---
## 2026-05-12 (Monday)
**FILES Panel and Editor Integration**
This week marked a significant milestone with the implementation of the FILES panel foundation featuring lazy-loading file tree capabilities. The implementation replaced react-ace with @uiw/react-codemirror to resolve React 19 compatibility issues and Vite dev server problems.
### Changes:
- Implemented FILES panel foundation with lazy-loading file tree
- Replaced react-ace with @uiw/react-codemirror
- Fixed flexbox scrolling for FILES panel sidebar
- Added shrink-0 to SESSION and PROJECT panels for proper flex layout
- Fixed file tree scrolling and text selection issues
- Fixed file tree backend recursion breaking lazy loading
- Fixed file tree showing entire workspace instead of project directory
- Fixed file tree duplication and corruption on expand
- Fixed file tree expand/collapse not rendering children
- Added navigation to project in Project Manager using slug
- Fixed ACE editor integration crash — React 19 compat, Vite ?url pattern, Error Boundary
- Fixed file tree duplication and corruption on expand
---
## 2026-05-11 (Sunday)
**Subagent Processing and Authentication Fixes**
This weekend's work focused on subagent processing improvements and authentication fixes, including fixes for subagent response handling, session data management, and adding the persona field to sign-in responses.
### Changes:
- Fixed subagent response handling and session data
- Added persona field to sign-in response
- Fixed JWT expiration bugs and drone heartbeat timeouts
- Added installation guides, config examples, and systemd service files
- Fixed subagent markdown output whitespace using .subagent-compact.gadget-markdown
- Applied CSS and style fixes for subagent display
- Updated build prompt
- Fixed auth: include persona field in sign-in response
---
## 2026-05-10 (Saturday)
**Workspace and Project Philosophy Shift**
This weekend involved a philosophical shift in how Workspaces and Projects are conceptualized and implemented, with improved separation of concerns and clearer boundaries between the two concepts.
### Changes:
- Applied philosophy shifts for Workspaces and Projects
- Updated workspace and project conceptual model
- Improved separation of concerns between workspace and project
- Chat interface fixes and cleanup
- Credential provider fix
- Ensure apiKey is presented to required consumers
---
## 2026-05-09 (Friday)
**Agentic Workflow Loop Rebuild**
This week involved a complete rebuild of the Agentic Workflow Loop to improve reliability and performance, including better error handling, improved checkpointing, and enhanced event emission capabilities.
### Changes:
- Re-built Agentic Workflow Loop with improved reliability
- Enhanced streaming response handling
- Implemented chat session auto-naming with IDE update
- Improved checkpointing and event emission
- GadgetLogTransportSocket and the drone-to-IDE log
---
## 2026-05-08 (Thursday)
**Chat Session Heartbeat and User Settings**
This week focused on implementing chat session heartbeat functionality and user settings management, ensuring that active sessions are properly tracked and can be recovered in case of unexpected disconnections.
### Changes:
- Implemented chat session heartbeat and session unlock
- Added user settings management
- Made reasoning effort configurable
- Added editable session name with cog icon
- Redesigned sign-in form and presentation
- Integrated persona field in User
- Reworked provider/model selection with save/cancel pattern
- Added navigation to return to Project Manager
- Quick fix for when logging.file.enabled is false
- Refactor: unify logging into @gadget/api as GadgetLog
---
## 2026-05-07 (Wednesday)
**Streaming Responses Implementation**
This week saw the implementation of streaming responses for AI model interactions, including proper handling of streaming data from Ollama and other providers with support for real-time token delivery and response completion tracking.
### Changes:
- Implemented streaming responses for Ollama
- Added streaming response handling and correctness improvements
- Created documentation for streaming responses
- Improved real-time token delivery and completion tracking
---
## 2026-05-06 (Tuesday)
**Agent Tool and Toolbox Refactoring**
This week involved significant refactoring of the agent tool and toolbox architecture, including unifying logging into @gadget/api as GadgetLog and restructuring the toolbox to support better tool registration and management.
### Changes:
- Refactored agent tool and toolbox architecture
- Unified logging into @gadget/api as GadgetLog
- Improved tool registration and management
- Reduced log spam through pre-task cleanup
- Agent, tools, toolbox, tool loop, AI environment
---
## 2026-05-05 (Monday)
**Basic Chat System Implementation**
This week marked a major milestone with the implementation of a working basic chat system, including provider, model, and mode selection capabilities, along with enhanced AgentService.process method functionality.
### Changes:
- Implemented basic chat system
- Added provider, model, and mode selections
- Enhanced AgentService.process method
- Implemented chat turn processing
- Added agent progress tracking
---
## 2026-05-03 (Saturday)
**Status Message Display Fix**
This weekend's work focused on fixing the status message display in the agent interface and implementing workspace mode management improvements with drone status message socket events.
### Changes:
- Fixed status message display in agent interface
- Implemented workspace mode management
- Added drone status message socket events
- Many fixes
---
## 2026-05-02 (Friday)
**Workspace Mode Switching**
This week involved implementing workspace mode switching functionality with proper validation, ensuring that workspace mode changes are validated before being applied, preventing invalid state transitions.
### Changes:
- Implemented workspace mode switching with validation
- Added session lock and workspace processing refinements
- Improved workspace management system
- Workspace management progress
---
## 2026-05-01 (Thursday)
**Welcome to The Grid**
This week marked the beginning of a new phase with the implementation of the "Welcome to The Grid" home view, including JWT processing fixes, session startup/shutdown/refactoring improvements, and the transition from Types.ObjectId to GadgetId.
### Changes:
- Implemented "Welcome to The Grid" home view
- Fixed JWT processing
- Refactored session startup, shutdown, and import
- Transitioned from Types.ObjectId to GadgetId
- Added return to Home on sign out
- More populate fixes
---
## 2026-04-30 (Wednesday)
**Socket Protocol Implementation**
This week focused on implementing and refining the socket protocol for drone-to-IDE communication, including defining missing socket event types, enforcing typed events in the frontend build, and adding a setting for max Socket.IO HTTP buffer size.
### Changes:
- Implemented socket protocol completeness
- Defined missing socket event types
- Enforced typed events in frontend build
- Added max Socket.IO HTTP buffer size setting
- Created drone manager
---
## 2026-04-29 (Tuesday)
**Workspace Persistence and Crash Recovery**
This week involved implementing workspace persistence and crash recovery capabilities, including adding workspace mode to the gadget-drone process, event emissions to the Agentic Workflow Loop, and drone-to-IDE event routing.
### Changes:
- Implemented workspace persistence and crash recovery
- Added workspace mode to gadget-drone process
- Added event emissions to Agentic Workflow Loop
- Implemented drone-to-IDE event routing
- Fixed type conflicts and implemented prompt submission
---
## 2026-04-28 (Monday)
**Dark Industrial Theme and JWT Authentication**
This week marked a significant visual and architectural milestone with the implementation of the dark industrial theme, Project Manager, and JWT authentication, replacing the previous sign-up concept with a more streamlined sign-in experience.
### Changes:
- Implemented dark industrial theme
- Added Project Manager
- Implemented JWT authentication
- Enforced theme correctly
- Added project service
- Updated DroneRegistration with workspaceDir
- Fix: Darken border colors to match dark industrial theme
- Theme enforced and correctly implemented; e2e tests added for theme
---
## 2026-04-27 (Sunday)
**Initial Commit**
The project was initialized with the initial commit, establishing the foundation for all subsequent development.
### Changes:
- Initial project commit
- Established basic project structure

View File

@ -16,33 +16,27 @@ or is aborted, the abort signal mechanism is cleaned up end-to-end.
``` ```
[Frontend] --abortWorkOrder(cb)--> [CodeSession] --abortWorkOrder(cb)--> [Drone] [Frontend] --abortWorkOrder(cb)--> [CodeSession] --abortWorkOrder(cb)--> [Drone]
| |
AgentService AgentService
.abortCurrentWorkOrder() .abortCurrentWorkOrder()
| |
abortController.abort() abortController.abort()
| |
AiService.abort() cb(true, "Abort signaled")
(calls AI provider's |
abort() which for
Ollama terminates the
HTTP request immediately)
|
cb(true, "Abort signaled")
|
[Frontend] <------- callback received, show "Aborting..." -----------------------+ [Frontend] <------- callback received, show "Aborting..." -----------------------+
| |
(AbortError thrown (AbortError thrown
in AI provider) in AI provider)
| |
AgentService catches AgentService catches
AbortError, emits AbortError, emits
workOrderComplete(turnId, true, "aborted") workOrderComplete(turnId, true, "aborted")
| |
[DroneSession] --workOrderComplete(turnId, true, "aborted")--> [CodeSession] [DroneSession] --workOrderComplete(turnId, true, "aborted")--> [CodeSession]
| | | |
turn.status = turn.status = "aborted" turn.status = turn.status = "aborted"
ChatTurnStatus.Aborted displayed subtly ChatTurnStatus.Aborted displayed subtly
``` ```
**Key design decision**: `workOrderComplete` is used with `success=true` and **Key design decision**: `workOrderComplete` is used with `success=true` and
@ -80,10 +74,6 @@ No `SocketEvents` changes needed — the drone listens on its own socket.
- `IAiChatOptions` - `IAiChatOptions`
- `IAiGenerateOptions` - `IAiGenerateOptions`
Also added a default (no-op) `abort()` method on the `AiApi` abstract class.
Provider-specific implementations override this to forcefully terminate
in-progress HTTP requests.
**`packages/ai/src/ollama.ts`** — In both `generate()` and `chat()`: **`packages/ai/src/ollama.ts`** — In both `generate()` and `chat()`:
1. Pre-request check: `if (options.signal?.aborted) throw ...` 1. Pre-request check: `if (options.signal?.aborted) throw ...`
@ -91,9 +81,6 @@ in-progress HTTP requests.
3. Per-chunk check at top of `for await` loop: 3. Per-chunk check at top of `for await` loop:
`if (options.signal?.aborted) throw new DOMException("The operation was aborted", "AbortError")` `if (options.signal?.aborted) throw new DOMException("The operation was aborted", "AbortError")`
Additionally, `OllamaAiApi` overrides `abort()` to call `this.client.abort()`,
which immediately terminates the underlying HTTP request to the Ollama server.
**`packages/ai/src/openai.ts`** — Same pattern in: **`packages/ai/src/openai.ts`** — Same pattern in:
- `generate()` — pre-check, pass `signal` to SDK, per-chunk in `for await` - `generate()` — pre-check, pass `signal` to SDK, per-chunk in `for await`
@ -121,18 +108,7 @@ and is detected downstream via `error.name === "AbortError"`.
``` ```
- `finally` block sets `this.abortController = null` - `finally` block sets `this.abortController = null`
- Public method `abortCurrentWorkOrder(): boolean` — calls - Public method `abortCurrentWorkOrder(): boolean` — calls
`this.abortController?.abort()` then `AiService.abort()`, returns `true` if `this.abortController?.abort()`, returns `true` if there was a controller
there was a controller. The `AiService.abort()` call provides provider-specific
forceful abort (e.g., `OllamaAiApi.abort()``this.client.abort()`).
**`gadget-drone/src/services/ai.ts`**:
- Property: `private activeApi: AiApi | null = null` — tracks the most recently
created `AiApi` instance
- `getApi()` stores each created instance on `this.activeApi`
- `chat()` and `generate()` clear `this.activeApi = null` in `finally` blocks
- Public method `abort(): void` — calls `this.activeApi?.abort()`, then clears
the reference
**`gadget-drone/src/gadget-drone.ts`**: **`gadget-drone/src/gadget-drone.ts`**:
@ -217,11 +193,10 @@ status: "processing" | "finished" | "aborted" | "error";
| `.gitignore` | Add root-level log file patterns | | `.gitignore` | Add root-level log file patterns |
| `packages/api/src/messages/ide.ts` | New `AbortWorkOrderCallback`, `AbortWorkOrderMessage` | | `packages/api/src/messages/ide.ts` | New `AbortWorkOrderCallback`, `AbortWorkOrderMessage` |
| `packages/api/src/messages/socket.ts` | Register `abortWorkOrder` in both event interfaces | | `packages/api/src/messages/socket.ts` | Register `abortWorkOrder` in both event interfaces |
| `packages/ai/src/api.ts` | `signal?: AbortSignal` on `IAiChatOptions`, `IAiGenerateOptions`; `abort()` on `AiApi` | | `packages/ai/src/api.ts` | `signal?: AbortSignal` on `IAiChatOptions`, `IAiGenerateOptions` |
| `packages/ai/src/ollama.ts` | Pre-request and per-chunk abort checks, signal pass-through; `abort()` override | | `packages/ai/src/ollama.ts` | Pre-request and per-chunk abort checks, signal pass-through |
| `packages/ai/src/openai.ts` | Same for `generate()`, `readStreamingChatCompletion()`, `readNonStreamingChatCompletion()` | | `packages/ai/src/openai.ts` | Same for `generate()`, `readStreamingChatCompletion()`, `readNonStreamingChatCompletion()` |
| `gadget-drone/src/services/agent.ts` | `AbortController` property, abort detection, `abortCurrentWorkOrder()` calls `AiService.abort()` | | `gadget-drone/src/services/agent.ts` | `AbortController` property, abort detection, `abortCurrentWorkOrder()` |
| `gadget-drone/src/services/ai.ts` | Track active `AiApi` instance, `abort()` method, cleanup in `finally` blocks |
| `gadget-drone/src/gadget-drone.ts` | `onAbortWorkOrder` socket handler | | `gadget-drone/src/gadget-drone.ts` | `onAbortWorkOrder` socket handler |
| `gadget-code/src/lib/code-session.ts` | Forward `abortWorkOrder` to drone | | `gadget-code/src/lib/code-session.ts` | Forward `abortWorkOrder` to drone |
| `gadget-code/src/lib/drone-session.ts` | Detect `"aborted"` in `workOrderComplete` | | `gadget-code/src/lib/drone-session.ts` | Detect `"aborted"` in `workOrderComplete` |
@ -239,16 +214,9 @@ status: "processing" | "finished" | "aborted" | "error";
event or change the `workOrderComplete` signature. event or change the `workOrderComplete` signature.
- The `AbortController` is created per-process() call and cleaned up in - The `AbortController` is created per-process() call and cleaned up in
`finally`. It is never shared across work orders. `finally`. It is never shared across work orders.
- The abort callback returns immediately after the controller is signaled and - The abort callback returns immediately after the controller is signaled. The
`AiService.abort()` is called. The AbortError propagates asynchronously AbortError propagates asynchronously through the SDK stream and is caught
through the SDK stream and is caught separately in the agent loop. separately in the agent loop.
- `AiService.abort()` provides provider-specific forceful abort (e.g., Ollama's
`client.abort()` terminates the HTTP request immediately). This is called
_in addition to_ the `AbortController.abort()` signal so that both the
AbortSignal chain and the provider-specific abort mechanism are triggered.
- The active `AiApi` instance is tracked on `AiService.activeApi` and is set to
`null` in the `finally` block of each `chat()`/`generate()` call, so the
reference is never stale.
- Subagent loops also receive the abort signal (via `this.abortController?.signal`) - Subagent loops also receive the abort signal (via `this.abortController?.signal`)
and abort naturally — no special subagent abort handling needed. and abort naturally — no special subagent abort handling needed.
- The Esc handler is a global `window` keydown listener. It is registered ONLY - The Esc handler is a global `window` keydown listener. It is registered ONLY

View File

@ -1,43 +0,0 @@
import type { Project } from '../lib/api';
interface ProjectPanelProps {
project: Project | null;
onNavigateToProject: () => void;
}
export default function ProjectPanel({ project, onNavigateToProject }: ProjectPanelProps) {
return (
<div className="border-b border-border-subtle shrink-0">
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
Project
</h3>
<button
title="Project Manager"
onClick={onNavigateToProject}
className="text-text-muted hover:text-text-primary transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
</button>
</div>
<div className="p-4 space-y-2">
{project ? (
<>
<div>
<div className="text-xs text-text-muted">Name</div>
<div className="text-text-primary">{project.name}</div>
</div>
<div>
<div className="text-xs text-text-muted">Slug</div>
<div className="font-mono text-xs text-text-secondary">{project.slug}</div>
</div>
</>
) : (
<div className="text-text-muted text-sm">Loading...</div>
)}
</div>
</div>
);
}

View File

@ -1,298 +0,0 @@
import { ChatSessionMode, type AiProvider, type ChatSession, type AiModel } from '../lib/api';
import { WorkspaceMode } from '../lib/types';
import WorkspaceModeIndicator from './WorkspaceModeIndicator';
interface SessionPanelProps {
session: ChatSession | null;
workspaceMode: WorkspaceMode;
sessionLocked: boolean;
providers: AiProvider[];
selectedProviderId: string;
selectedModelId: string;
sessionReasoningEffort: string;
sessionNumCtx: number;
isEditingProvider: boolean;
editProviderId: string;
editModelId: string;
isUpdatingProvider: boolean;
isEditingName: boolean;
editName: string;
isUpdatingName: boolean;
isUpdatingMode: boolean;
isUpdatingReasoning: boolean;
isUpdatingNumCtx: boolean;
onWorkspaceModeChange: (mode: WorkspaceMode) => void;
onModeChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
onReasoningChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
onNumCtxChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onNumCtxCommit: (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => void;
onStartEditProvider: () => void;
onSaveProvider: () => void;
onCancelEditProvider: () => void;
onEditProviderIdChange: (id: string) => void;
onEditModelIdChange: (id: string) => void;
onStartEditName: () => void;
onSaveName: () => void;
onCancelEditName: () => void;
onEditNameChange: (name: string) => void;
getProviderName: (id: string) => string;
getModelName: (providerId: string, modelId: string) => string;
getSortedModels: (providerId: string) => AiProvider['models'];
truncateModelName: (name: string, maxLength?: number) => string;
getSelectedModelCapabilities: () => { hasThinking: boolean } | null;
getSelectedModel: () => AiModel | null;
}
export default function SessionPanel({
session,
workspaceMode,
sessionLocked,
providers,
selectedProviderId,
selectedModelId,
sessionReasoningEffort,
sessionNumCtx,
isEditingProvider,
editProviderId,
editModelId,
isUpdatingProvider,
isEditingName,
editName,
isUpdatingName,
isUpdatingMode,
isUpdatingReasoning,
isUpdatingNumCtx,
onWorkspaceModeChange,
onModeChange,
onReasoningChange,
onNumCtxChange,
onNumCtxCommit,
onStartEditProvider,
onSaveProvider,
onCancelEditProvider,
onEditProviderIdChange,
onEditModelIdChange,
onStartEditName,
onSaveName,
onCancelEditName,
onEditNameChange,
getProviderName,
getModelName,
getSortedModels,
truncateModelName,
getSelectedModelCapabilities,
getSelectedModel,
}: SessionPanelProps) {
return (
<div className="border-b border-border-subtle shrink-0">
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
Session
</h3>
<WorkspaceModeIndicator
mode={workspaceMode}
onChange={onWorkspaceModeChange}
disabled={!sessionLocked || isEditingProvider}
/>
</div>
<div className="p-4 space-y-3">
<div>
<div className="text-xs text-text-muted">Name</div>
{isEditingName ? (
<div className="flex gap-2 mt-1">
<input
type="text"
value={editName}
onChange={(e) => onEditNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); onSaveName(); }
if (e.key === 'Escape') { e.preventDefault(); onCancelEditName(); }
}}
className="flex-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none"
autoFocus
/>
<button
title="Save"
onClick={onSaveName}
disabled={!editName.trim() || isUpdatingName}
className="text-green-500 hover:text-green-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors self-center"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
<button
title="Cancel"
onClick={onCancelEditName}
disabled={isUpdatingName}
className="text-red-500 hover:text-red-400 disabled:opacity-30 transition-colors self-center"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
) : (
<div className="flex gap-2 items-center mt-1">
<div className="text-text-primary flex-1">{session?.name}</div>
<button
title="Edit name"
onClick={onStartEditName}
className="text-text-muted hover:text-text-primary transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
)}
</div>
{isEditingProvider ? (
<div className="flex gap-2">
<div className="flex-1">
<div className="text-xs text-text-muted">Provider</div>
<select
value={editProviderId || ''}
onChange={(e) => { onEditProviderIdChange(e.target.value); onEditModelIdChange(''); }}
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none"
>
<option value="">-- Select Provider --</option>
{providers.map((provider) => (
<option key={provider._id} value={provider._id}>
{provider.name}
</option>
))}
</select>
</div>
<div className="flex-1">
<div className="text-xs text-text-muted">Model</div>
<select
value={editModelId || ''}
onChange={(e) => onEditModelIdChange(e.target.value)}
disabled={!editProviderId}
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed truncate"
>
{!editProviderId ? (
<option value="">Select provider first</option>
) : (
<>
<option value="">-- Select Model --</option>
{getSortedModels(editProviderId).map((model) => (
<option key={model.id} value={model.id}>
{truncateModelName(model.name)}
</option>
))}
</>
)}
</select>
</div>
<div className="flex items-end gap-1 pb-1">
<button
title="Save"
onClick={onSaveProvider}
disabled={!editProviderId || !editModelId || isUpdatingProvider}
className="text-green-500 hover:text-green-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
<button
title="Cancel"
onClick={onCancelEditProvider}
disabled={isUpdatingProvider}
className="text-red-500 hover:text-red-400 disabled:opacity-30 transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
) : (
<div className="flex gap-2">
<div className="flex-1">
<div className="text-xs text-text-muted">Provider</div>
<div className="text-text-primary text-sm mt-1">{getProviderName(selectedProviderId)}</div>
</div>
<div className="flex-1">
<div className="text-xs text-text-muted">Model</div>
<div
className="text-text-primary text-sm mt-1 truncate"
title={getModelName(selectedProviderId, selectedModelId)}
>
{getModelName(selectedProviderId, selectedModelId)}
</div>
</div>
<button
title="Change provider/model..."
onClick={onStartEditProvider}
className="self-end pb-1 text-text-muted hover:text-text-primary transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
)}
<div>
<div className="text-xs text-text-muted">Mode</div>
<select
value={session?.mode || ChatSessionMode.Build}
onChange={onModeChange}
disabled={isUpdatingMode}
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{Object.values(ChatSessionMode).map((mode) => (
<option key={mode} value={mode}>
{mode.charAt(0).toUpperCase() + mode.slice(1)}
</option>
))}
</select>
</div>
<div>
<div className="text-xs text-text-muted">Reasoning</div>
<select
value={sessionReasoningEffort}
onChange={onReasoningChange}
disabled={isUpdatingReasoning || !getSelectedModelCapabilities()?.hasThinking}
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
title={
!getSelectedModelCapabilities()?.hasThinking
? "Selected model does not support reasoning"
: "Controls how much the model thinks before responding"
}
>
<option value="off">Off</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
{providers.find(p => p._id === selectedProviderId)?.apiType !== 'openai' && (
<div>
<div className="text-xs text-text-muted">Context Window</div>
<div className="flex items-center gap-2 mt-1">
<input
type="range"
min={16384}
max={getSelectedModel()?.contextWindow || 131072}
step={8192}
value={sessionNumCtx}
onChange={onNumCtxChange}
onMouseUp={onNumCtxCommit}
onTouchEnd={onNumCtxCommit}
className="flex-1 h-2 bg-bg-tertiary rounded appearance-none cursor-pointer accent-brand"
/>
<span className="text-xs text-text-secondary w-16 text-right tabular-nums">
{sessionNumCtx.toLocaleString()}
</span>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -358,7 +358,6 @@ export interface ChatSession {
provider: string | AiProvider; provider: string | AiProvider;
selectedModel: string; selectedModel: string;
reasoningEffort?: string; reasoningEffort?: string;
numCtx?: number;
stats: ChatSessionStats; stats: ChatSessionStats;
pins: Array<{ _id?: string; content: string }>; pins: Array<{ _id?: string; content: string }>;
} }
@ -422,7 +421,6 @@ export interface ChatTurn {
session: string | ChatSession; session: string | ChatSession;
provider: string | AiProvider; provider: string | AiProvider;
llm: string; llm: string;
numCtx?: number;
mode: ChatSessionMode; mode: ChatSessionMode;
status: "processing" | "finished" | "aborted" | "error"; status: "processing" | "finished" | "aborted" | "error";
prompts: ChatTurnPrompts; prompts: ChatTurnPrompts;

View File

@ -3,8 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import { socketClient } from '../lib/socket'; import { socketClient } from '../lib/socket';
import { chatSessionApi, projectApi, providerApi, type ChatSession, type ChatTurn, type ChatTurnBlock, ChatTurnStats, ChatSessionMode, type AiProvider, type Project } from '../lib/api'; import { chatSessionApi, projectApi, providerApi, type ChatSession, type ChatTurn, type ChatTurnBlock, ChatTurnStats, ChatSessionMode, type AiProvider, type Project } from '../lib/api';
import { WorkspaceMode } from '../lib/types'; import { WorkspaceMode } from '../lib/types';
import SessionPanel from '../components/SessionPanel'; import WorkspaceModeIndicator from '../components/WorkspaceModeIndicator';
import ProjectPanel from '../components/ProjectPanel';
import FilesPanel from '../components/FilesPanel'; import FilesPanel from '../components/FilesPanel';
import LogPanel from '../components/LogPanel'; import LogPanel from '../components/LogPanel';
import ChatTurnComponent from '../components/ChatTurn'; import ChatTurnComponent from '../components/ChatTurn';
@ -68,8 +67,6 @@ export default function ChatSessionView() {
const [selectedProviderId, setSelectedProviderId] = useState<string>(''); const [selectedProviderId, setSelectedProviderId] = useState<string>('');
const [selectedModelId, setSelectedModelId] = useState<string>(''); const [selectedModelId, setSelectedModelId] = useState<string>('');
const [sessionReasoningEffort, setSessionReasoningEffort] = useState<string>('off'); const [sessionReasoningEffort, setSessionReasoningEffort] = useState<string>('off');
const [sessionNumCtx, setSessionNumCtx] = useState<number>(131072);
const [isUpdatingNumCtx, setIsUpdatingNumCtx] = useState(false);
const [isEditingProvider, setIsEditingProvider] = useState(false); const [isEditingProvider, setIsEditingProvider] = useState(false);
const [editProviderId, setEditProviderId] = useState(''); const [editProviderId, setEditProviderId] = useState('');
const [editModelId, setEditModelId] = useState(''); const [editModelId, setEditModelId] = useState('');
@ -216,15 +213,6 @@ export default function ChatSessionView() {
setSelectedProviderId(providerId || ''); setSelectedProviderId(providerId || '');
setSelectedModelId(sessionData.selectedModel || ''); setSelectedModelId(sessionData.selectedModel || '');
setSessionReasoningEffort(sessionData.reasoningEffort || 'off'); setSessionReasoningEffort(sessionData.reasoningEffort || 'off');
// Initialize numCtx: persisted value wins, otherwise model's contextWindow
if (sessionData.numCtx) {
setSessionNumCtx(sessionData.numCtx);
} else {
const provider = allProviders.find(p => p._id === providerId);
const model = provider?.models.find(m => m.id === sessionData.selectedModel);
setSessionNumCtx(model?.contextWindow || 131072);
}
// Set connection state to connecting - will update on socket connect // Set connection state to connecting - will update on socket connect
setConnectionState('connecting'); setConnectionState('connecting');
@ -804,16 +792,13 @@ export default function ChatSessionView() {
} }
}; };
const getSelectedModel = useCallback(() => { const getSelectedModelCapabilities = useCallback(() => {
const provider = providers.find(p => p._id === selectedProviderId); const provider = providers.find(p => p._id === selectedProviderId);
if (!provider) return null; if (!provider) return null;
return provider.models.find(m => m.id === selectedModelId) || null; const model = provider.models.find(m => m.id === selectedModelId);
return model?.capabilities || null;
}, [providers, selectedProviderId, selectedModelId]); }, [providers, selectedProviderId, selectedModelId]);
const getSelectedModelCapabilities = useCallback(() => {
return getSelectedModel()?.capabilities || null;
}, [getSelectedModel]);
const handleReasoningChange = async (e: React.ChangeEvent<HTMLSelectElement>) => { const handleReasoningChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
if (!session) return; if (!session) return;
@ -835,28 +820,6 @@ export default function ChatSessionView() {
} }
}; };
const handleNumCtxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSessionNumCtx(parseInt(e.target.value, 10));
};
const handleNumCtxCommit = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
if (!session || isUpdatingNumCtx) return;
const newValue = parseInt(e.currentTarget.value, 10);
setIsUpdatingNumCtx(true);
try {
const updatedSession = await chatSessionApi.update(session._id, {
numCtx: newValue
});
setSession(updatedSession);
showToast(`Context window set to ${newValue.toLocaleString()}`);
} catch (err) {
showToast(`Failed to change context window: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
setIsUpdatingNumCtx(false);
}
};
const getProviderName = (id: string) => { const getProviderName = (id: string) => {
return providers.find(p => p._id === id)?.name ?? 'None'; return providers.find(p => p._id === id)?.name ?? 'None';
}; };
@ -895,13 +858,6 @@ export default function ChatSessionView() {
setSession(updatedSession); setSession(updatedSession);
setSelectedProviderId(editProviderId); setSelectedProviderId(editProviderId);
setSelectedModelId(editModelId); setSelectedModelId(editModelId);
// Reset numCtx to new model's max context window
const newProvider = providers.find(p => p._id === editProviderId);
const newModel = newProvider?.models.find(m => m.id === editModelId);
const newMaxCtx = newModel?.contextWindow || 131072;
setSessionNumCtx(newMaxCtx);
setIsEditingProvider(false); setIsEditingProvider(false);
showToast('Provider and model updated'); showToast('Provider and model updated');
} catch (err) { } catch (err) {
@ -1208,52 +1164,232 @@ export default function ChatSessionView() {
{/* Sidebar */} {/* Sidebar */}
<div className="w-80 border-l border-border-subtle bg-bg-secondary flex flex-col"> <div className="w-80 border-l border-border-subtle bg-bg-secondary flex flex-col">
<SessionPanel {/* SESSION Panel */}
session={session} <div className="border-b border-border-subtle shrink-0">
workspaceMode={workspaceMode} <div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary">
sessionLocked={sessionLocked} <h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
providers={providers} Session
selectedProviderId={selectedProviderId} </h3>
selectedModelId={selectedModelId} <WorkspaceModeIndicator
sessionReasoningEffort={sessionReasoningEffort} mode={workspaceMode}
sessionNumCtx={sessionNumCtx} onChange={handleWorkspaceModeChange}
isEditingProvider={isEditingProvider} disabled={!sessionLocked || isEditingProvider}
editProviderId={editProviderId} />
editModelId={editModelId} </div>
isUpdatingProvider={isUpdatingProvider} <div className="p-4 space-y-3">
isEditingName={isEditingName} <div>
editName={editName} <div className="text-xs text-text-muted">Name</div>
isUpdatingName={isUpdatingName} {isEditingName ? (
isUpdatingMode={isUpdatingMode} <div className="flex gap-2 mt-1">
isUpdatingReasoning={isUpdatingReasoning} <input
isUpdatingNumCtx={isUpdatingNumCtx} type="text"
onWorkspaceModeChange={handleWorkspaceModeChange} value={editName}
onModeChange={handleModeChange} onChange={(e) => setEditName(e.target.value)}
onReasoningChange={handleReasoningChange} onKeyDown={(e) => {
onNumCtxChange={handleNumCtxChange} if (e.key === 'Enter') { e.preventDefault(); handleSaveName(); }
onNumCtxCommit={handleNumCtxCommit} if (e.key === 'Escape') { e.preventDefault(); handleCancelEditName(); }
onStartEditProvider={handleStartEditProvider} }}
onSaveProvider={handleSaveProvider} className="flex-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none"
onCancelEditProvider={handleCancelEditProvider} autoFocus
onEditProviderIdChange={setEditProviderId} />
onEditModelIdChange={setEditModelId} <button
onStartEditName={handleStartEditName} title="Save"
onSaveName={handleSaveName} onClick={handleSaveName}
onCancelEditName={handleCancelEditName} disabled={!editName.trim() || isUpdatingName}
onEditNameChange={setEditName} className="text-green-500 hover:text-green-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors self-center"
getProviderName={getProviderName} >
getModelName={getModelName} <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
getSortedModels={getSortedModels} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
truncateModelName={truncateModelName} </svg>
getSelectedModelCapabilities={getSelectedModelCapabilities} </button>
getSelectedModel={getSelectedModel} <button
/> title="Cancel"
onClick={handleCancelEditName}
disabled={isUpdatingName}
className="text-red-500 hover:text-red-400 disabled:opacity-30 transition-colors self-center"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
) : (
<div className="flex gap-2 items-center mt-1">
<div className="text-text-primary flex-1">{session?.name}</div>
<button
title="Edit name"
onClick={handleStartEditName}
className="text-text-muted hover:text-text-primary transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
)}
</div>
{isEditingProvider ? (
<div className="flex gap-2">
<div className="flex-1">
<div className="text-xs text-text-muted">Provider</div>
<select
value={editProviderId || ''}
onChange={(e) => { setEditProviderId(e.target.value); setEditModelId(''); }}
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none"
>
<option value="">-- Select Provider --</option>
{providers.map((provider) => (
<option key={provider._id} value={provider._id}>
{provider.name}
</option>
))}
</select>
</div>
<div className="flex-1">
<div className="text-xs text-text-muted">Model</div>
<select
value={editModelId || ''}
onChange={(e) => setEditModelId(e.target.value)}
disabled={!editProviderId}
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed truncate"
>
{!editProviderId ? (
<option value="">Select provider first</option>
) : (
<>
<option value="">-- Select Model --</option>
{getSortedModels(editProviderId).map((model) => (
<option key={model.id} value={model.id}>
{truncateModelName(model.name)}
</option>
))}
</>
)}
</select>
</div>
<div className="flex items-end gap-1 pb-1">
<button
title="Save"
onClick={handleSaveProvider}
disabled={!editProviderId || !editModelId || isUpdatingProvider}
className="text-green-500 hover:text-green-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
<button
title="Cancel"
onClick={handleCancelEditProvider}
disabled={isUpdatingProvider}
className="text-red-500 hover:text-red-400 disabled:opacity-30 transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
) : (
<div className="flex gap-2">
<div className="flex-1">
<div className="text-xs text-text-muted">Provider</div>
<div className="text-text-primary text-sm mt-1">{getProviderName(selectedProviderId)}</div>
</div>
<div className="flex-1">
<div className="text-xs text-text-muted">Model</div>
<div
className="text-text-primary text-sm mt-1 truncate"
title={getModelName(selectedProviderId, selectedModelId)}
>
{getModelName(selectedProviderId, selectedModelId)}
</div>
</div>
<button
title="Change provider/model..."
onClick={handleStartEditProvider}
className="self-end pb-1 text-text-muted hover:text-text-primary transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
)}
<div>
<div className="text-xs text-text-muted">Mode</div>
<select
value={session?.mode || ChatSessionMode.Build}
onChange={handleModeChange}
disabled={isUpdatingMode}
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{Object.values(ChatSessionMode).map((mode) => (
<option key={mode} value={mode}>
{mode.charAt(0).toUpperCase() + mode.slice(1)}
</option>
))}
</select>
</div>
<div>
<div className="text-xs text-text-muted">Reasoning</div>
<select
value={sessionReasoningEffort}
onChange={handleReasoningChange}
disabled={isUpdatingReasoning || !getSelectedModelCapabilities()?.hasThinking}
className="w-full mt-1 px-2 py-1.5 bg-bg-tertiary border border-border-default rounded text-text-primary text-sm focus:border-brand focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
title={
!getSelectedModelCapabilities()?.hasThinking
? "Selected model does not support reasoning"
: "Controls how much the model thinks before responding"
}
>
<option value="off">Off</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
</div>
</div>
<ProjectPanel {/* PROJECT Panel */}
project={project} <div className="border-b border-border-subtle shrink-0">
onNavigateToProject={() => navigate(`/projects/${project?.slug}`)} <div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary">
/> <h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
Project
</h3>
<button
title="Project Manager"
onClick={() => navigate(`/projects/${project?.slug}`)}
className="text-text-muted hover:text-text-primary transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
</button>
</div>
<div className="p-4 space-y-2">
{project ? (
<>
<div>
<div className="text-xs text-text-muted">Name</div>
<div className="text-text-primary">{project.name}</div>
</div>
<div>
<div className="text-xs text-text-muted">Slug</div>
<div className="font-mono text-xs text-text-secondary">{project.slug}</div>
</div>
</>
) : (
<div className="text-text-muted text-sm">Loading...</div>
)}
</div>
</div>
{/* FILES Panel */}
<FilesPanel <FilesPanel
workspaceMode={workspaceMode} workspaceMode={workspaceMode}
onFileSelect={(path) => { onFileSelect={(path) => {

View File

@ -12,7 +12,6 @@ import {
providerApi, providerApi,
} from "../lib/api"; } from "../lib/api";
import { socketClient } from "../lib/socket"; import { socketClient } from "../lib/socket";
import gabAiImage from "./assets/gab-ai.jpeg";
interface ProjectManagerProps { interface ProjectManagerProps {
user: User | null; user: User | null;
@ -232,11 +231,7 @@ interface ProjectInspectorProps {
onUpdate: () => void; onUpdate: () => void;
} }
function ProjectInspector({ function ProjectInspector({ project, onDelete, onUpdate }: ProjectInspectorProps) {
project,
onDelete,
onUpdate,
}: ProjectInspectorProps) {
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
@ -284,9 +279,7 @@ function ProjectInspector({
</div> </div>
<div className="p-4 bg-bg-secondary border border-border-default rounded"> <div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-1">Slug</div> <div className="text-sm text-text-muted mb-1">Slug</div>
<div className="font-mono text-text-primary"> <div className="font-mono text-text-primary">{project.slug}</div>
{project.slug}
</div>
</div> </div>
</div> </div>
@ -659,152 +652,118 @@ function NewChatSessionModal({
} }
}; };
const affiliateHref = "https://gab.ai/?ref=c7182f6e";
return ( return (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
<div className="bg-bg-secondary border border-border-default rounded p-6 w-full max-w-2xl flex gap-6"> <div className="bg-bg-secondary border border-border-default rounded p-6 w-full max-w-md">
<div className="w-48 flex-shrink-0 flex flex-col"> <h3 className="text-lg font-semibold mb-4">New Chat Session</h3>
<a {selectedDrone && (
href={affiliateHref} <div className="mb-4 p-3 bg-bg-tertiary border border-border-default rounded">
target="_blank" <div className="text-xs text-text-muted mb-1">Selected Drone</div>
rel="noopener noreferrer" <div className="font-mono text-sm text-text-primary">
className="flex-shrink-0" {selectedDrone.hostname}
>
<img src={gabAiImage} alt="Gab AI" className="w-full rounded" />
</a>
<p className="flex-1 flex items-center justify-center text-center text-lg text-text-secondary px-1">
Need big-brain inference for this session?
</p>
<p className="flex-1 flex items-center justify-center text-left text-sm text-text-secondary px-1">
Sign up for Gab AI and get 250 credits to see if it's right for you!
</p>
<a
href={affiliateHref}
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0"
>
<button className="w-full px-4 py-2 border border-[#00d178] text-[#00d178] rounded hover:bg-[#00d178] hover:text-white text-sm font-medium transition-colors">
Get Started
</button>
</a>
</div>
<div className="flex-1 border-l border-border-subtle pl-6">
<h3 className="text-lg font-semibold mb-4">New Chat Session</h3>
{selectedDrone && (
<div className="mb-4 p-3 bg-bg-tertiary border border-border-default rounded">
<div className="text-xs text-text-muted mb-1">Selected Drone</div>
<div className="font-mono text-sm text-text-primary">
{selectedDrone.hostname}
</div>
<div className="text-xs text-text-muted truncate">
{selectedDrone.workspaceDir}
</div>
</div> </div>
)} <div className="text-xs text-text-muted truncate">
<form onSubmit={handleSubmit} className="space-y-4"> {selectedDrone.workspaceDir}
<div>
<label className="block text-sm text-text-secondary mb-1">
Session Name (optional)
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
placeholder="Auto-generated from first prompt"
/>
</div> </div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-text-secondary mb-1">
Session Name (optional)
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
placeholder="Auto-generated from first prompt"
/>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1">
AI Provider *
</label>
<select
value={selectedProviderId}
onChange={(e) => {
setSelectedProviderId(e.target.value);
setSelectedModel("");
}}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
>
<option value="">Select a provider</option>
{providers.map((provider) => (
<option key={provider._id} value={provider._id}>
{provider.name} ({provider.apiType})
</option>
))}
</select>
</div>
{selectedProvider && (
<div> <div>
<label className="block text-sm text-text-secondary mb-1"> <label className="block text-sm text-text-secondary mb-1">
AI Provider * Model *
</label> </label>
<select <select
value={selectedProviderId} value={selectedModel}
onChange={(e) => { onChange={(e) => setSelectedModel(e.target.value)}
setSelectedProviderId(e.target.value);
setSelectedModel("");
}}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none" className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
disabled={selectedProvider.models.length === 0}
> >
<option value="">Select a provider</option> <option value="">Select a model</option>
{providers.map((provider) => ( {[...selectedProvider.models].sort((a, b) => a.name.localeCompare(b.name)).map((model) => (
<option key={provider._id} value={provider._id}> <option key={model.id} value={model.id}>
{provider.name} ({provider.apiType}) {model.name}{" "}
{model.parameterLabel ? `(${model.parameterLabel})` : ""}
</option> </option>
))} ))}
</select> </select>
{selectedProvider.models.length === 0 && (
<p className="text-xs text-text-muted mt-1">
No models discovered. Run `pnpm cli provider probe{" "}
{selectedProvider._id}` to discover models.
</p>
)}
</div> </div>
)}
{selectedProvider && ( <div>
<div> <label className="block text-sm text-text-secondary mb-1">
<label className="block text-sm text-text-secondary mb-1"> Mode
Model * </label>
</label> <select
<select value={mode}
value={selectedModel} onChange={(e) => setMode(e.target.value)}
onChange={(e) => setSelectedModel(e.target.value)} className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none" >
disabled={selectedProvider.models.length === 0} <option value="plan">Plan - Planning and brainstorming</option>
> <option value="build">Build - Building and coding</option>
<option value="">Select a model</option> <option value="test">Test - Testing and debugging</option>
{[...selectedProvider.models] <option value="ship">Ship - Finalizing and shipping</option>
.sort((a, b) => a.name.localeCompare(b.name)) <option value="dev">Dev - Working on Gadget Code itself</option>
.map((model) => ( </select>
<option key={model.id} value={model.id}> </div>
{model.name}{" "}
{model.parameterLabel
? `(${model.parameterLabel})`
: ""}
</option>
))}
</select>
{selectedProvider.models.length === 0 && (
<p className="text-xs text-text-muted mt-1">
No models discovered. Run `pnpm cli provider probe{" "}
{selectedProvider._id}` to discover models.
</p>
)}
</div>
)}
<div> <div className="flex gap-3 pt-4">
<label className="block text-sm text-text-secondary mb-1"> <button
Mode type="submit"
</label> disabled={creating || !selectedProviderId || !selectedModel}
<select className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50 flex-1"
value={mode} >
onChange={(e) => setMode(e.target.value)} {creating ? "Creating..." : "Create Session"}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none" </button>
> <button
<option value="plan">Plan - Planning and brainstorming</option> type="button"
<option value="build">Build - Building and coding</option> onClick={onCancel}
<option value="test">Test - Testing and debugging</option> className="px-4 py-2 border border-border-default text-text-secondary rounded hover:bg-bg-tertiary transition-colors"
<option value="ship">Ship - Finalizing and shipping</option> >
<option value="dev">Dev - Working on Gadget Code itself</option> Cancel
</select> </button>
</div> </div>
</form>
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={creating || !selectedProviderId || !selectedModel}
className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50 flex-1"
>
{creating ? "Creating..." : "Create Session"}
</button>
<button
type="button"
onClick={onCancel}
className="px-4 py-2 border border-border-default text-text-secondary rounded hover:bg-bg-tertiary transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div> </div>
</div> </div>
); );
@ -815,9 +774,7 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
const { slug } = useParams(); const { slug } = useParams();
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [selectedProject, setSelectedProject] = useState<Project | null>(null); const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>( const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(null);
null,
);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showNewForm, setShowNewForm] = useState(false); const [showNewForm, setShowNewForm] = useState(false);
@ -882,10 +839,7 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
console.error("Failed to lock drone session"); console.error("Failed to lock drone session");
return; return;
} }
localStorage.setItem( localStorage.setItem('dtp_drone_registration', JSON.stringify(selectedDrone));
"dtp_drone_registration",
JSON.stringify(selectedDrone),
);
navigate(`/projects/${selectedProject._id}/chat-session/${sessionId}`); navigate(`/projects/${selectedProject._id}/chat-session/${sessionId}`);
} catch (err) { } catch (err) {
console.error("Failed to open chat session", err); console.error("Failed to open chat session", err);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

View File

@ -1,29 +0,0 @@
declare module "*.jpeg" {
const src: string;
export default src;
}
declare module "*.jpg" {
const src: string;
export default src;
}
declare module "*.png" {
const src: string;
export default src;
}
declare module "*.gif" {
const src: string;
export default src;
}
declare module "*.svg" {
const src: string;
export default src;
}
declare module "*.webp" {
const src: string;
export default src;
}

View File

@ -179,7 +179,6 @@ class ChatSessionController extends DtpController {
selectedModel: string; selectedModel: string;
mode: ChatSessionMode; mode: ChatSessionMode;
reasoningEffort: "off" | "low" | "medium" | "high"; reasoningEffort: "off" | "low" | "medium" | "high";
numCtx: number;
}> = {}; }> = {};
if (updates.name !== undefined) { if (updates.name !== undefined) {
@ -205,12 +204,6 @@ class ChatSessionController extends DtpController {
} }
allowedUpdates.reasoningEffort = updates.reasoningEffort; allowedUpdates.reasoningEffort = updates.reasoningEffort;
} }
if (updates.numCtx !== undefined) {
if (typeof updates.numCtx !== "number" || !Number.isInteger(updates.numCtx) || updates.numCtx < 16384 || updates.numCtx % 8192 !== 0) {
throw new Error(`Invalid numCtx: ${updates.numCtx}. Must be an integer >= 16384 and a multiple of 8192.`);
}
allowedUpdates.numCtx = updates.numCtx;
}
const session = await ChatSessionService.update( const session = await ChatSessionService.update(
res.locals.chatSession._id, res.locals.chatSession._id,

View File

@ -30,7 +30,6 @@ export const ChatSessionSchema = new Schema<IChatSession>({
enum: ["off", "low", "medium", "high"], enum: ["off", "low", "medium", "high"],
default: "off", default: "off",
}, },
numCtx: { type: Number },
stats: { stats: {
turnCount: { type: Number, default: 0, required: true }, turnCount: { type: Number, default: 0, required: true },
toolCallCount: { type: Number, default: 0, required: true }, toolCallCount: { type: Number, default: 0, required: true },

View File

@ -69,7 +69,6 @@ export const ChatTurnSchema = new Schema<IChatTurn>({
enum: ["off", "low", "medium", "high"], enum: ["off", "low", "medium", "high"],
default: "off", default: "off",
}, },
numCtx: { type: Number },
mode: { mode: {
type: String, type: String,
enum: ChatSessionMode, enum: ChatSessionMode,

View File

@ -202,7 +202,6 @@ class ChatSessionService extends DtpService {
selectedModel: string; selectedModel: string;
mode: ChatSessionMode; mode: ChatSessionMode;
reasoningEffort: ReasoningEffort; reasoningEffort: ReasoningEffort;
numCtx: number;
}>, }>,
): Promise<IChatSession> { ): Promise<IChatSession> {
const session = await ChatSession.findById(chatSessionId); const session = await ChatSession.findById(chatSessionId);
@ -231,9 +230,6 @@ class ChatSessionService extends DtpService {
if (updates.reasoningEffort !== undefined) { if (updates.reasoningEffort !== undefined) {
session.reasoningEffort = updates.reasoningEffort; session.reasoningEffort = updates.reasoningEffort;
} }
if (updates.numCtx !== undefined) {
session.numCtx = updates.numCtx;
}
await session.save(); await session.save();
@ -280,7 +276,6 @@ class ChatSessionService extends DtpService {
provider: session.provider, provider: session.provider,
llm: session.selectedModel, llm: session.selectedModel,
reasoningEffort: (session as IChatSession).reasoningEffort || "off", reasoningEffort: (session as IChatSession).reasoningEffort || "off",
numCtx: (session as IChatSession).numCtx,
mode: session.mode, mode: session.mode,
status: ChatTurnStatus.Processing, status: ChatTurnStatus.Processing,
prompts: { prompts: {

View File

@ -218,7 +218,7 @@ class AgentService extends GadgetService {
try { try {
response = await AiService.chat( response = await AiService.chat(
turn.provider, turn.provider,
this.buildDroneModelConfig(turn.provider, turn.llm, currentReasoning, turn.numCtx), this.buildDroneModelConfig(turn.provider, turn.llm, currentReasoning),
chatOptions, chatOptions,
this.makeStreamHandler(socket), this.makeStreamHandler(socket),
); );
@ -337,7 +337,6 @@ class AgentService extends GadgetService {
abortCurrentWorkOrder(): boolean { abortCurrentWorkOrder(): boolean {
if (!this.abortController) return false; if (!this.abortController) return false;
this.abortController.abort(); this.abortController.abort();
AiService.abort();
return true; return true;
} }
@ -474,7 +473,6 @@ class AgentService extends GadgetService {
provider: IAiProvider | GadgetId, provider: IAiProvider | GadgetId,
modelId: string, modelId: string,
reasoning: boolean | "low" | "medium" | "high", reasoning: boolean | "low" | "medium" | "high",
numCtxOverride?: number,
): Omit<IDroneModelConfig, "provider"> { ): Omit<IDroneModelConfig, "provider"> {
const settings = this.getModelSettings(provider, modelId); const settings = this.getModelSettings(provider, modelId);
return { return {
@ -485,7 +483,7 @@ class AgentService extends GadgetService {
topP: settings?.topP ?? 0.9, topP: settings?.topP ?? 0.9,
topK: settings?.topK ?? 40, topK: settings?.topK ?? 40,
numPredict: settings?.numPredict ?? -1, // -1 = unlimited (Ollama) numPredict: settings?.numPredict ?? -1, // -1 = unlimited (Ollama)
numCtx: numCtxOverride ?? settings?.numCtx ?? 131072, // session override > model settings > 128k fallback numCtx: settings?.numCtx ?? 131072, // 128k fallback
maxCompletionTokens: settings?.maxCompletionTokens ?? 16384, // 16k fallback maxCompletionTokens: settings?.maxCompletionTokens ?? 16384, // 16k fallback
}, },
}; };
@ -627,7 +625,7 @@ class AgentService extends GadgetService {
const response = await AiService.chat( const response = await AiService.chat(
provider, provider,
this.buildDroneModelConfig(provider, turn.llm, false, turn.numCtx), this.buildDroneModelConfig(provider, turn.llm, false),
chatOptions, chatOptions,
streamHandler, streamHandler,
); );

View File

@ -18,7 +18,6 @@ const aiEnv: IAiEnvironment = {
import { IAiProvider as DbAiProvider, GadgetId, type IDroneModelConfig } from "@gadget/api"; import { IAiProvider as DbAiProvider, GadgetId, type IDroneModelConfig } from "@gadget/api";
import { GadgetService } from "../lib/service.js"; import { GadgetService } from "../lib/service.js";
import { import {
AiApi,
type IAiChatOptions, type IAiChatOptions,
type IAiChatResponse, type IAiChatResponse,
type IAiGenerateOptions, type IAiGenerateOptions,
@ -48,8 +47,6 @@ class AiService extends GadgetService {
return "svc:ai"; return "svc:ai";
} }
private activeApi: AiApi | null = null;
async start(): Promise<void> { async start(): Promise<void> {
this.log.info("started"); this.log.info("started");
} }
@ -106,11 +103,7 @@ class AiService extends GadgetService {
}); });
const api = this.getApi(config); const api = this.getApi(config);
const modelConfig: IAiModelConfig = { ...model, provider: config }; const modelConfig: IAiModelConfig = { ...model, provider: config };
try { return api.generate(modelConfig, options, streamCallback);
return await api.generate(modelConfig, options, streamCallback);
} finally {
this.activeApi = null;
}
} }
async chat( async chat(
@ -128,23 +121,11 @@ class AiService extends GadgetService {
}); });
const api = this.getApi(config); const api = this.getApi(config);
const modelConfig: IAiModelConfig = { ...model, provider: config }; const modelConfig: IAiModelConfig = { ...model, provider: config };
try { return await api.chat(modelConfig, options, streamCallback);
return await api.chat(modelConfig, options, streamCallback);
} finally {
this.activeApi = null;
}
} }
getApi(provider: AiProviderConfig) { getApi(provider: AiProviderConfig) {
const api = createAiApi(aiEnv, provider, this.log); return createAiApi(aiEnv, provider, this.log);
this.activeApi = api;
return api;
}
/** Forcefully abort any in-progress request on the active API instance. */
abort(): void {
this.activeApi?.abort();
this.activeApi = null;
} }
} }

View File

@ -196,15 +196,6 @@ export abstract class AiApi {
streamCallback?: IAiResponseStreamFn, streamCallback?: IAiResponseStreamFn,
): Promise<IAiChatResponse>; ): Promise<IAiChatResponse>;
/**
* Forcefully abort any in-progress API request.
* Provider-specific implementations (e.g., Ollama) should terminate
* the underlying HTTP request immediately. Default is no-op.
*/
abort(): void {
// Override in provider-specific implementations
}
protected assertNonEmptyChatResponse(response: IAiChatResponse): void { protected assertNonEmptyChatResponse(response: IAiChatResponse): void {
const hasResponse = response.response.trim().length > 0; const hasResponse = response.response.trim().length > 0;
const hasThinking = !!response.thinking?.trim(); const hasThinking = !!response.thinking?.trim();

View File

@ -34,11 +34,6 @@ export class OllamaAiApi extends AiApi {
}); });
} }
/** Forcefully abort any in-progress Ollama API request. */
override abort(): void {
this.client.abort();
}
async listModels(): Promise<IAiModelListResult> { async listModels(): Promise<IAiModelListResult> {
const response = await this.client.list(); const response = await this.client.list();
const models = response.models.map((model) => { const models = response.models.map((model) => {

View File

@ -35,7 +35,6 @@ export interface IChatSession {
provider: IAiProvider | GadgetId; provider: IAiProvider | GadgetId;
selectedModel: string; selectedModel: string;
reasoningEffort?: ReasoningEffort; reasoningEffort?: ReasoningEffort;
numCtx?: number;
stats: { stats: {
turnCount: number; turnCount: number;
toolCallCount: number; toolCallCount: number;

View File

@ -86,7 +86,6 @@ export interface IChatTurn {
provider: IAiProvider | GadgetId; provider: IAiProvider | GadgetId;
llm: string; // id/name of the model used to process the prompt llm: string; // id/name of the model used to process the prompt
reasoningEffort?: ReasoningEffort; reasoningEffort?: ReasoningEffort;
numCtx?: number;
mode: ChatSessionMode; mode: ChatSessionMode;
status: ChatTurnStatus; status: ChatTurnStatus;
prompts: IChatTurnPrompts; prompts: IChatTurnPrompts;

View File

@ -1,27 +0,0 @@
#!/usr/bin/env node
/**
* forever-sleep.js
* A script that sleeps, wakes up every 5 seconds, logs to stdout, then goes back to sleep.
*/
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
async function main() {
console.log('forever-sleep: Starting...');
while (true) {
const timestamp = new Date().toISOString();
console.log(`forever-sleep: ${timestamp} - Waking up (sleeping for 5 seconds)...`);
await sleep(5000);
const timestampAfter = new Date().toISOString();
console.log(`forever-sleep: ${timestampAfter} - Back to sleep...`);
}
}
main().catch(err => {
console.error('forever-sleep: Error:', err);
process.exit(1);
});