Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ Copy `.env.sample` to `.env`. Key settings:
| `EXCLUDED_TOOLS` | *(empty)* | Comma-separated tools to disable |
| `AGENT_PLUGIN_DIRS` | *(empty)* | Extra agent plugin directories |
| `BASE_PATH` | `$HOME` | Sandbox root for file browsing |
| `TLS_CERT_FILE` | *(empty)* | Path to TLS certificate file (enables HTTPS) |
| `TLS_KEY_FILE` | *(empty)* | Path to TLS private key file |

Auth mode is auto-detected: `GITHUB_CLIENT_ID` → OAuth, `AUTH_TOKEN` → static token, neither → open access.

Expand All @@ -83,7 +85,7 @@ import { CopilotSession, SessionEvent } from '@github/copilot-sdk';
import { CustomAgentConfig, MCPServerConfig } from '@github/copilot-sdk';
```

Sessions are created via `CopilotBridge.createSession()` and emit events through `session.on(callback)`. Events include `assistant.message`, `assistant.message_delta`, `tool.execution_start`, `tool.execution_complete`, `session.idle`, `session.error`, and `assistant.reasoning`.
Sessions are created via `CopilotBridge.createSession()` and emit events through `session.on(callback)`. Events include `assistant.message`, `assistant.message_delta`, `tool.execution_start`, `tool.execution_complete`, `assistant.turn_end`, `session.error`, `session.mode_changed`, `subagent.started`, `subagent.completed`, and `assistant.reasoning`. Note: the CLI idle signal is `assistant.turn_end` (not `session.idle`).

Custom agents are `.md` files with YAML frontmatter parsed by `parseAgentMarkdown()` in `session-manager.ts`.

Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,13 @@ Copy `.env.sample` to `.env` and adjust values as needed. All settings are optio
| `HOST` | `127.0.0.1` | Bind address (`0.0.0.0` for all interfaces, or a specific IP) |
| `GITHUB_CLIENT_ID` | *(none)* | GitHub OAuth App Client ID — enables "Sign in with GitHub" device flow (recommended) |
| `AUTH_TOKEN` | *(none)* | Static auth token — the web UI prompts for the token on login |
| `ALLOWED_ORIGINS` | *(none)* | Comma-separated CORS origins (only needed if UI is served from a different origin) |
| `SESSION_TIMEOUT_MS` | `1800000` (30 min) | Idle timeout before session SDK resources are released |
| `BASE_PATH` | User home dir | Base directory for the session folder browser (set to `/` for full access) |
| `CLI_URL` | *(none)* | External Copilot CLI server (`host:port`) for remote CLI connections |
| `MCP_SERVERS` | *(none)* | JSON config for MCP server connections |
| `EXCLUDED_TOOLS` | *(none)* | Comma-separated tools to disable |
| `AGENT_PLUGIN_DIRS` | *(none)* | Comma-separated extra directories to scan for custom agent plugins |
| `ALLOWED_ORIGINS` | *(none)* | Comma-separated CORS origins (only needed if UI is served from a different origin) |
| `TLS_CERT_FILE` | *(none)* | Path to TLS certificate file (enables HTTPS when paired with `TLS_KEY_FILE`) |
| `TLS_KEY_FILE` | *(none)* | Path to TLS private key file |

Expand Down
8 changes: 8 additions & 0 deletions client/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,11 @@

/* Use class-based dark mode so the theme toggle works */
@custom-variant dark (&:where(.dark, .dark *));

html, body {
overflow: hidden;
overscroll-behavior: none;
height: 100%;
width: 100%;
position: fixed;
}
7 changes: 5 additions & 2 deletions client/src/components/chat/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,11 @@ export function ChatPanel({
useEffect(() => {
if (needsScrollRef.current && messages.length > 0) {
needsScrollRef.current = false;
// Double RAF ensures DOM layout is complete before scrolling
requestAnimationFrame(() => {
bottomRef.current?.scrollIntoView();
requestAnimationFrame(() => {
bottomRef.current?.scrollIntoView();
});
});
}
}, [messages]);
Expand Down Expand Up @@ -97,7 +100,7 @@ export function ChatPanel({
const isActive = status === 'thinking' || status === 'tool_use' || streamingContent !== null;

return (
<div ref={containerRef} className="relative h-full overflow-y-auto px-4 py-4" onScroll={handleScroll}>
<div ref={containerRef} className="relative h-full overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-4" onScroll={handleScroll}>
<div className="max-w-3xl mx-auto space-y-4">
{messages.map((msg, idx) => (
<div key={msg.id}>
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/chat/MessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[85%] rounded-2xl px-4 py-2.5 ${
className={`max-w-[85%] rounded-2xl px-4 py-2.5 overflow-hidden min-w-0 ${
isUser
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100'
Expand All @@ -70,7 +70,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
{isUser ? (
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
) : (
<div className="prose prose-sm dark:prose-invert max-w-none [&_pre]:bg-gray-200 [&_pre]:dark:bg-gray-900 [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:overflow-x-auto [&_code]:text-xs [&_pre]:relative">
<div className="prose prose-sm dark:prose-invert max-w-none break-words [&_pre]:bg-gray-200 [&_pre]:dark:bg-gray-900 [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:overflow-x-auto [&_code]:text-xs [&_pre]:relative [&_table]:block [&_table]:overflow-x-auto [&_pre]:max-w-full">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
Expand Down
41 changes: 39 additions & 2 deletions client/src/components/layout/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ export function AppShell() {
ws.onLifecycle(refreshSessions);
}, [ws, refreshSessions]);

// Update mode/model/agent when CLI changes them
useEffect(() => {
ws.onConfigChanged((data) => {
if (data.sessionId !== activeSessionId) return;
if (data.mode) setSessionMode(data.mode);
if (data.modelId) setSessionModelId(data.modelId);
if (data.agent !== undefined) setCurrentAgent(data.agent || null);
});
}, [ws, activeSessionId]);

// Close sidebar on mobile when selecting a session
const handleSelectSession = useCallback(
async (sessionId: string) => {
Expand All @@ -97,9 +107,12 @@ export function AppShell() {
ws.subscribe(sessionId);
// History is loaded via WS subscribe (server sends history frame)

if (isMobile) setSidebarOpen(false);
// Check media query directly to avoid stale closure issues
if (window.matchMedia('(max-width: 767px)').matches) {
setSidebarOpen(false);
}
},
[activeSessionId, isMobile, ws],
[activeSessionId, ws],
);

const handleNewSession = useCallback(() => {
Expand Down Expand Up @@ -194,6 +207,23 @@ export function AppShell() {
return () => { stale = true; };
}, [activeSessionId]);

// Re-fetch mode/model/agent when session goes idle (may have changed during turn)
const prevStatus = useRef(ws.status);
const prevSessionForStatus = useRef(activeSessionId);
useEffect(() => {
let stale = false;
// Only re-fetch on genuine idle transitions, not session switches
const isSessionSwitch = prevSessionForStatus.current !== activeSessionId;
if (!isSessionSwitch && prevStatus.current !== 'idle' && ws.status === 'idle' && activeSessionId) {
api.getSessionMode(activeSessionId).then((m) => { if (!stale) setSessionMode(m.mode); }).catch(() => {});
api.getSessionModel(activeSessionId).then((m) => { if (!stale) setSessionModelId(m.modelId); }).catch(() => {});
api.getSessionAgent(activeSessionId).then((r) => { if (!stale) setCurrentAgent(r.name || null); }).catch(() => {});
}
prevStatus.current = ws.status;
prevSessionForStatus.current = activeSessionId;
return () => { stale = true; };
}, [ws.status, activeSessionId]);

// Fetch quota on mount and periodically
useEffect(() => {
const fetchQuota = () => {
Expand Down Expand Up @@ -344,6 +374,13 @@ export function AppShell() {
<span className="hidden sm:inline">Reconnecting...</span>
</span>
)}
{ws.activeTaskCount > 0 && (
<span className="text-xs text-purple-600 dark:text-purple-400 flex items-center gap-1" title={`${ws.activeTaskCount} background task${ws.activeTaskCount > 1 ? 's' : ''} running`}>
<span className="w-2 h-2 rounded-full bg-purple-500 animate-pulse" />
<span className="hidden sm:inline">{ws.activeTaskCount} task{ws.activeTaskCount > 1 ? 's' : ''}</span>
<span className="sm:hidden">{ws.activeTaskCount}</span>
</span>
)}
<button
onClick={cycleTheme}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] flex items-center justify-center"
Expand Down
14 changes: 12 additions & 2 deletions client/src/components/sessions/DirectoryPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,18 @@ export function DirectoryPicker({ currentDirectory, onSelect }: DirectoryPickerP
currentDirectory === d.path ? 'bg-blue-50 dark:bg-blue-950/30 text-blue-600' : 'hover:bg-gray-50 dark:hover:bg-gray-900'
}`}
>
<span className="truncate">{d.path.split('/').pop()}</span>
<span className="text-gray-400 ml-auto flex-shrink-0">{d.sessionCount}</span>
<div className="flex-1 min-w-0">
<div className="truncate">{d.path.split('/').pop()}</div>
{d.branch && (
<div className="flex items-center gap-0.5 text-gray-400 dark:text-gray-500 mt-0.5">
<svg className="w-3 h-3 flex-shrink-0" fill="none" viewBox="0 0 16 16" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 3a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm6 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM5 7v2a2 2 0 0 0 2 2h1M5 7c0 3 2.5 4 6 4" />
</svg>
<span className="truncate">{d.branch}</span>
</div>
)}
</div>
<span className="text-gray-400 flex-shrink-0">{d.sessionCount}</span>
</button>
))}
<button
Expand Down
9 changes: 1 addition & 8 deletions client/src/components/sessions/SessionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,7 @@ function SessionCard({
</span>
) : null}
</div>
{session.context?.branch && (
<div className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-0.5 mt-0.5 min-w-0">
<svg className="w-3 h-3 flex-shrink-0" fill="none" viewBox="0 0 16 16" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 3a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm6 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM5 7v2a2 2 0 0 0 2 2h1M5 7c0 3 2.5 4 6 4" />
</svg>
<span className="truncate min-w-0">{session.context.branch}</span>
</div>
)}

</div>
</button>
);
Expand Down
26 changes: 26 additions & 0 deletions client/src/hooks/useWebSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface UseWebSocketReturn {
liveToolCalls: ToolUseInfo[];
permissionRequest: unknown | null;
userInputRequest: (UserInputRequest & { sessionId: string }) | null;
activeTaskCount: number;
planChanged: string | null;
sendMessage: (sessionId: string, content: string) => void;
subscribe: (sessionId: string) => void;
Expand All @@ -22,6 +23,7 @@ interface UseWebSocketReturn {
addMessages: (msgs: ChatMessage[]) => void;
onLifecycle: (cb: () => void) => void;
onReconnect: (cb: () => void) => void;
onConfigChanged: (cb: (data: { sessionId: string; mode?: string; modelId?: string; agent?: string }) => void) => void;
forceReconnect: () => void;
clearPlanChanged: () => void;
}
Expand All @@ -38,6 +40,7 @@ export function useWebSocket(): UseWebSocketReturn {
const [userInputRequest, setUserInputRequest] = useState<(UserInputRequest & { sessionId: string }) | null>(null);
const [reasoning, setReasoning] = useState<string | null>(null);
const [planChanged, setPlanChanged] = useState<string | null>(null);
const [activeTaskCount, setActiveTaskCount] = useState(0);
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const reconnectDelay = useRef(1000);
const disposedRef = useRef(false);
Expand All @@ -46,6 +49,7 @@ export function useWebSocket(): UseWebSocketReturn {
const activeSubscriptions = useRef<Set<string>>(new Set());
const lifecycleCallback = useRef<(() => void) | null>(null);
const reconnectCallback = useRef<(() => void) | null>(null);
const configChangedCallback = useRef<((data: { sessionId: string; mode?: string; modelId?: string; agent?: string }) => void) | null>(null);
const hasConnectedOnce = useRef(false);

const connect = useCallback(() => {
Expand Down Expand Up @@ -229,6 +233,21 @@ export function useWebSocket(): UseWebSocketReturn {
setPlanChanged(msg.operation);
break;

case 'tasks_changed':
setActiveTaskCount(msg.count ?? 0);
break;

case 'config_changed':
if (configChangedCallback.current) {
configChangedCallback.current({
sessionId: msg.sessionId,
mode: msg.mode,
modelId: msg.modelId,
agent: msg.agent,
});
}
break;

case 'history': {
// Convert file-based events to ChatMessages + ToolCallEvents
const historyMessages: ChatMessage[] = [];
Expand Down Expand Up @@ -377,6 +396,7 @@ export function useWebSocket(): UseWebSocketReturn {
setReasoning(null);
setLiveToolCalls([]);
setPlanChanged(null);
setActiveTaskCount(0);
streamBuffer.current = '';
reasoningBuffer.current = '';
}, []);
Expand All @@ -393,6 +413,10 @@ export function useWebSocket(): UseWebSocketReturn {
reconnectCallback.current = cb;
}, []);

const onConfigChanged = useCallback((cb: (data: { sessionId: string; mode?: string; modelId?: string; agent?: string }) => void) => {
configChangedCallback.current = cb;
}, []);

const forceReconnect = useCallback(() => {
if (wsRef.current) {
reconnectDelay.current = 0;
Expand All @@ -412,6 +436,7 @@ export function useWebSocket(): UseWebSocketReturn {
liveToolCalls,
permissionRequest,
userInputRequest,
activeTaskCount,
planChanged,
sendMessage,
subscribe,
Expand All @@ -423,6 +448,7 @@ export function useWebSocket(): UseWebSocketReturn {
addMessages,
onLifecycle,
onReconnect,
onConfigChanged,
forceReconnect,
clearPlanChanged,
};
Expand Down
3 changes: 3 additions & 0 deletions client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface DirectoryInfo {
path: string;
sessionCount: number;
lastUsed: string;
branch?: string;
}

export type SessionStatus = 'idle' | 'thinking' | 'tool_use';
Expand Down Expand Up @@ -83,4 +84,6 @@ export type ServerMessage =
| { type: 'user_message'; sessionId: string; content: string }
| { type: 'task_complete'; sessionId: string; summary: string }
| { type: 'plan_changed'; sessionId: string; operation: string }
| { type: 'config_changed'; sessionId: string; mode?: string; modelId?: string; agent?: string }
| { type: 'tasks_changed'; sessionId: string; count: number }
| { type: 'pong' };
11 changes: 11 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
| `POST` | `/api/sessions` | Create new session (`{ model, workingDirectory }`) |
| `POST` | `/api/sessions/:id/resume` | Resume an existing session |
| `GET` | `/api/sessions/:id/history` | Get session conversation history |
| `GET` | `/api/sessions/:id/status` | Get session status (idle/busy/inactive) |
| `POST` | `/api/sessions/:id/abort` | Abort current message |
| `DELETE` | `/api/sessions/:id` | Delete a session |
| `GET` | `/api/sessions/:id/mode` | Get session mode (interactive/plan/autopilot) |
Expand All @@ -22,6 +23,7 @@
| `DELETE` | `/api/sessions/:id/agent` | Deselect agent (back to default) |
| `GET` | `/api/sessions/:id/plan` | Get session plan |
| `PUT` | `/api/sessions/:id/plan` | Update session plan (`{ content }`) |
| `GET` | `/api/agents` | List all agents with source info |
| `GET` | `/api/sessions/:id/workspace/files` | List workspace files |
| `GET` | `/api/sessions/:id/workspace/file?path=...` | Read workspace file |
| `GET` | `/api/models` | List available models |
Expand All @@ -33,6 +35,7 @@
| `GET` | `/api/permissions/rules` | List permission rules |
| `POST` | `/api/permissions/rules` | Add permission rule |
| `DELETE` | `/api/permissions/rules/:id` | Delete permission rule |
| `POST` | `/api/sessions/:id/reload-agents` | Reload agents from disk |

## Authentication Endpoints

Expand Down Expand Up @@ -64,11 +67,19 @@ Connect to `ws://host:3001/ws/chat` (add `?token=<JWT>` if auth is enabled; the
### Server → Client

```json
{ "type": "history", "sessionId": "...", "events": [...] }
{ "type": "delta", "sessionId": "...", "content": "..." }
{ "type": "complete", "sessionId": "...", "content": "..." }
{ "type": "reasoning", "sessionId": "...", "content": "..." }
{ "type": "reasoning_delta", "sessionId": "...", "content": "..." }
{ "type": "tool_use", "sessionId": "...", "name": "...", "toolCallId": "...", "input": {} }
{ "type": "tool_complete", "sessionId": "...", "name": "...", "toolCallId": "...", "result": {} }
{ "type": "status", "sessionId": "...", "state": "thinking|idle|tool_use" }
{ "type": "config_changed", "sessionId": "...", "mode": "...", "modelId": "...", "agent": "..." }
{ "type": "tasks_changed", "sessionId": "...", "count": 0 }
{ "type": "plan_changed", "sessionId": "...", "operation": "update" }
{ "type": "task_complete", "sessionId": "...", "summary": "..." }
{ "type": "user_message", "sessionId": "...", "content": "..." }
{ "type": "error", "sessionId": "...", "message": "..." }
{ "type": "permission_request", "sessionId": "...", "request": {} }
{ "type": "user_input_request", "sessionId": "...", "question": "...", "choices": [...], "allowFreeform": true }
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ copilot-remote/
│ ├── App.tsx
│ ├── components/
│ │ ├── layout/ # AppShell, Sidebar, BottomBar
│ │ ├── sessions/ # SessionList, SessionSwitcher, NewSessionDialog, PlanViewer, WorkspaceFilesViewer
│ │ ├── sessions/ # SessionList, SessionSwitcher, DirectoryPicker, NewSessionDialog, PlanViewer, WorkspaceFilesViewer
│ │ ├── chat/ # ChatPanel, MessageBubble, StreamingMessage, ToolUseIndicator, PermissionDialog, UserInputDialog
│ │ ├── auth/ # LoginPage
│ │ ├── controls/ # ModelSelector, MessageInput, ModeSwitcher, AgentSelector
Expand Down
17 changes: 14 additions & 3 deletions server/src/channels/web/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,26 +492,37 @@ export function createRoutes(sessionManager: SessionManager): Router {
try {
// Extract unique working directories from session metadata
const sessions = await sessionManager.listSessions();
const dirs = new Map<string, { path: string; sessionCount: number; lastUsed: Date }>();
const dirs = new Map<string, { path: string; sessionCount: number; lastUsed: Date; branch?: string }>();

for (const s of sessions) {
if (s.context?.cwd) {
const existing = dirs.get(s.context.cwd);
if (existing) {
existing.sessionCount++;
if (s.modifiedTime > existing.lastUsed) existing.lastUsed = s.modifiedTime;
if (s.modifiedTime > existing.lastUsed) {
existing.lastUsed = s.modifiedTime;
if (s.context.branch) existing.branch = s.context.branch;
}
} else {
dirs.set(s.context.cwd, {
path: s.context.cwd,
sessionCount: 1,
lastUsed: s.modifiedTime,
branch: s.context.branch,
});
}
}
}

const sorted = [...dirs.values()].sort((a, b) => b.lastUsed.getTime() - a.lastUsed.getTime());
res.json({ directories: sorted });
// Resolve live git branches (same as /sessions endpoint)
const enriched = await Promise.all(
sorted.map(async (d) => ({
...d,
branch: (await getLiveBranch(d.path)) ?? d.branch,
})),
);
res.json({ directories: enriched });
} catch (err) {
res.status(500).json({ error: String(err) });
}
Expand Down
Loading