Inspired by Amp: pick the workspace a dashboard chat starts in

A fresh dashboard /chat always spawned the TUI in the dashboard process's
launch directory, so from a phone or any browser there was no way to aim
a new session at a specific repository. Amp's runners now serve many
directories and the web composer offers a picker of the runner's projects
and discovered git checkouts; this ports that mechanism onto the surface
Hermes already has: the dashboard is the phone/web front, the host's
projects.db + repo-discovery cache are the served directories.

- GET /api/chat/workspaces: the profile's projects (with folders) and
  discovered repos (session-derived + scanned), default_cwd, home;
  ?scan=1 rescans desktop.repo_scan_roots on the host, so headless
  installs (no Desktop to populate the cache) discover repos too.
- /api/pty?cwd=<dir>: validated (existing directory, fail-closed 400
  via the PTY error path) and forwarded to the TUI child as HERMES_CWD
  (self-spawned gateway cwd) + HERMES_TUI_CWD (explicit cwd on
  session.create for the dashboard's in-memory gateway, whose own cwd
  is the launch dir). Resumed sessions ignore it.
- ui-tui: session.create carries cwd when HERMES_TUI_CWD is set, so
  /new inside a dashboard chat stays in the picked workspace too.
- web: workspace selector in the Chat rail above "New chat" (projects,
  repos by recency, Other path…, rescan), remembered per profile in
  localStorage; 17 locales.
- docs: web-dashboard.md rail + REST sections.

Live E2E: real `hermes dashboard` under a scratch HOME with two git
repos under desktop.repo_scan_roots -> /api/chat/workspaces?scan=1
lists both; /api/pty?cwd=repo-a -> TUI status bar shows ~/code/repo-a;
/api/pty?cwd=<missing> -> "Working directory does not exist" + close.
This commit is contained in:
teknium1
2026-09-20 20:34:41 -07:00
committed by Teknium
parent a2fbbf482d
commit d9ca819cc4
31 changed files with 646 additions and 4 deletions

View File

@@ -25,6 +25,7 @@ import { AlertCircle, MessageSquarePlus, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import { ChatWorkspacePicker } from "@/components/ChatWorkspacePicker";
import { useI18n } from "@/i18n";
import { api, type SessionInfo } from "@/lib/api";
import { cn, timeAgo } from "@/lib/utils";
@@ -45,6 +46,14 @@ interface ChatSessionListProps {
* omitted, we fall back to clearing the resume param ourselves.
*/
onNewChat?: () => void;
/**
* Workspace a FRESH chat starts in (absolute host path, "" = server
* default). Owned by ChatPage, which sends it as `/api/pty?cwd=`; the
* picker renders here beside "New chat" so the choice sits next to the
* action it affects. Omit both to hide the picker.
*/
workspaceCwd?: string;
onWorkspaceChange?: (cwd: string) => void;
}
function rowLabel(session: SessionInfo, untitled: string): string {
@@ -61,6 +70,8 @@ export function ChatSessionList({
className,
onPicked,
onNewChat,
workspaceCwd,
onWorkspaceChange,
}: ChatSessionListProps) {
const { t } = useI18n();
const [, setSearchParams] = useSearchParams();
@@ -242,6 +253,14 @@ export function ChatSessionList({
</Button>
</div>
{onWorkspaceChange && (
<ChatWorkspacePicker
profile={profile}
value={workspaceCwd ?? ""}
onChange={onWorkspaceChange}
/>
)}
<Button
outlined
size="sm"

View File

@@ -0,0 +1,159 @@
/**
* ChatWorkspacePicker — choose which host directory a FRESH dashboard chat
* starts in.
*
* The dashboard is the "drive Hermes from a phone or any browser" surface,
* yet every new /chat used to spawn in the dashboard process's launch
* directory with no way to say "work in ~/code/foo". The Desktop sidebar
* already knows the user's projects and discovered repos; this exposes the
* same list here (GET /api/chat/workspaces) so a fresh chat can be aimed at
* one of them, or at a typed path. The choice is sent as `/api/pty?cwd=` and
* only affects fresh chats — a resumed session keeps its own workspace.
*
* The value is owned by ChatPage (persisted per profile) so the picker can be
* changed at any time and applies on the next "New chat".
*/
import { Button } from "@nous-research/ui/ui/components/button";
import { Input } from "@nous-research/ui/ui/components/input";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { FolderGit2, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "@/i18n";
import { api, type ChatWorkspacesResponse } from "@/lib/api";
import { abbreviateHomePath, workspaceOptions } from "@/lib/chat-workspaces";
import { cn } from "@/lib/utils";
const CUSTOM_VALUE = "\u0000custom";
interface ChatWorkspacePickerProps {
/** Management profile the chat is scoped to (its projects.db + sessions). */
profile?: string;
/** Picked workspace (absolute host path) or "" for the server default. */
value: string;
onChange: (cwd: string) => void;
className?: string;
}
export function ChatWorkspacePicker({
profile,
value,
onChange,
className,
}: ChatWorkspacePickerProps) {
const { t } = useI18n();
const [data, setData] = useState<ChatWorkspacesResponse | null>(null);
const [loading, setLoading] = useState(false);
const [customOpen, setCustomOpen] = useState(false);
const [draft, setDraft] = useState(value);
const requestRef = useRef(0);
const load = useCallback(
(scan: boolean) => {
const token = ++requestRef.current;
void api
.getChatWorkspaces(profile, scan)
.then((res) => {
if (token !== requestRef.current) return;
setData(res);
})
.catch(() => {
// Best-effort: the picker degrades to default + custom path.
})
.finally(() => {
if (token === requestRef.current) setLoading(false);
});
},
[profile],
);
const rescan = useCallback(() => {
setLoading(true);
load(true);
}, [load]);
useEffect(() => {
load(false);
}, [load]);
const options = useMemo(() => (data ? workspaceOptions(data) : []), [data]);
const home = data?.home ?? "";
// A persisted custom path (or a repo that dropped out of discovery) still
// renders as the selected row instead of snapping back to the default.
const valueListed = !value || options.some((o) => o.value === value);
const defaultLabel = data?.default_cwd
? `${t.sessions.workspaceDefault} · ${abbreviateHomePath(data.default_cwd, home)}`
: t.sessions.workspaceDefault;
const onSelect = useCallback(
(next: string) => {
if (next === CUSTOM_VALUE) {
setDraft(value);
setCustomOpen(true);
return;
}
setCustomOpen(false);
onChange(next);
},
[onChange, value],
);
const commitDraft = useCallback(() => {
const trimmed = draft.trim();
setCustomOpen(false);
if (trimmed !== value) onChange(trimmed);
}, [draft, onChange, value]);
return (
<div className={cn("flex flex-col gap-1.5 px-2 pb-2", className)}>
<div className="flex items-center gap-2 text-xs">
<div className="flex items-center gap-1.5 text-text-tertiary">
<FolderGit2 className="h-3.5 w-3.5" />
<span className="text-display tracking-wider">{t.sessions.workspace}</span>
</div>
<Button
ghost
size="icon"
onClick={rescan}
disabled={loading}
aria-label={t.sessions.workspaceRescan}
title={t.sessions.workspaceRescan}
className="ml-auto text-text-secondary hover:text-foreground"
>
<RefreshCw className={cn(loading && "animate-spin")} />
</Button>
</div>
<Select
className="min-w-0"
onValueChange={onSelect}
value={customOpen ? CUSTOM_VALUE : value}
aria-label={t.sessions.workspace}
>
<SelectOption value="">{defaultLabel}</SelectOption>
{options.map((opt) => (
<SelectOption key={opt.value} value={opt.value}>
{opt.label}
</SelectOption>
))}
{!valueListed && !customOpen && (
<SelectOption value={value}>{abbreviateHomePath(value, home)}</SelectOption>
)}
<SelectOption value={CUSTOM_VALUE}>{t.sessions.workspaceCustom}</SelectOption>
</Select>
{customOpen && (
<Input
autoFocus
value={draft}
placeholder={home ? `${home}/…` : "/path/to/project"}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitDraft}
onKeyDown={(e) => {
if (e.key === "Enter") commitDraft();
if (e.key === "Escape") setCustomOpen(false);
}}
className="h-8 text-xs"
/>
)}
</div>
);
}

View File

@@ -165,6 +165,10 @@ export const af: Translations = {
failedToDeleteSelected: "Kon nie gekose sessies skrap nie",
resumeInChat: "Hervat in Klets",
newChat: "Nuwe klets",
workspace: "werkruimte",
workspaceDefault: "Verstek",
workspaceRescan: "Herskandeer bewaarplekke",
workspaceCustom: "Ander pad…",
previousPage: "Vorige bladsy",
nextPage: "Volgende bladsy",
roles: {

View File

@@ -165,6 +165,10 @@ export const de: Translations = {
failedToDeleteSelected: "Ausgewählte Sitzungen konnten nicht gelöscht werden",
resumeInChat: "Im Chat fortsetzen",
newChat: "Neuer Chat",
workspace: "Arbeitsbereich",
workspaceDefault: "Standard",
workspaceRescan: "Repositories neu scannen",
workspaceCustom: "Anderer Pfad…",
previousPage: "Vorherige Seite",
nextPage: "Nächste Seite",
roles: {

View File

@@ -194,6 +194,10 @@ export const en: Translations = {
failedToDeleteSelected: "Failed to delete selected sessions",
resumeInChat: "Resume in Chat",
newChat: "New chat",
workspace: "workspace",
workspaceDefault: "Default",
workspaceRescan: "Rescan repositories",
workspaceCustom: "Other path…",
previousPage: "Previous page",
nextPage: "Next page",
roles: {

View File

@@ -165,6 +165,10 @@ export const es: Translations = {
failedToDeleteSelected: "No se pudieron eliminar las sesiones seleccionadas",
resumeInChat: "Reanudar en el chat",
newChat: "Nuevo chat",
workspace: "espacio de trabajo",
workspaceDefault: "Predeterminado",
workspaceRescan: "Volver a buscar repositorios",
workspaceCustom: "Otra ruta…",
previousPage: "Página anterior",
nextPage: "Página siguiente",
roles: {

View File

@@ -165,6 +165,10 @@ export const fr: Translations = {
failedToDeleteSelected: "Échec de la suppression des sessions sélectionnées",
resumeInChat: "Reprendre dans le chat",
newChat: "Nouveau chat",
workspace: "espace de travail",
workspaceDefault: "Par défaut",
workspaceRescan: "Rechercher à nouveau les dépôts",
workspaceCustom: "Autre chemin…",
previousPage: "Page précédente",
nextPage: "Page suivante",
roles: {

View File

@@ -165,6 +165,10 @@ export const ga: Translations = {
failedToDeleteSelected: "Theip ar scriosadh na seisiún roghnaithe",
resumeInChat: "Lean ar aghaidh sa chomhrá",
newChat: "Comhrá nua",
workspace: "spás oibre",
workspaceDefault: "Réamhshocrú",
workspaceRescan: "Athscan na stórtha",
workspaceCustom: "Cosán eile…",
previousPage: "Leathanach roimhe seo",
nextPage: "An chéad leathanach eile",
roles: {

View File

@@ -165,6 +165,10 @@ export const hu: Translations = {
failedToDeleteSelected: "Nem sikerült törölni a kijelölt munkameneteket",
resumeInChat: "Folytatás a csevegésben",
newChat: "Új csevegés",
workspace: "munkaterület",
workspaceDefault: "Alapértelmezett",
workspaceRescan: "Tárolók újrakeresése",
workspaceCustom: "Másik útvonal…",
previousPage: "Előző oldal",
nextPage: "Következő oldal",
roles: {

View File

@@ -165,6 +165,10 @@ export const it: Translations = {
failedToDeleteSelected: "Impossibile eliminare le sessioni selezionate",
resumeInChat: "Riprendi nella chat",
newChat: "Nuova chat",
workspace: "area di lavoro",
workspaceDefault: "Predefinito",
workspaceRescan: "Riesamina i repository",
workspaceCustom: "Altro percorso…",
previousPage: "Pagina precedente",
nextPage: "Pagina successiva",
roles: {

View File

@@ -165,6 +165,10 @@ export const ja: Translations = {
failedToDeleteSelected: "選択したセッションの削除に失敗しました",
resumeInChat: "チャットで再開",
newChat: "新しいチャット",
workspace: "ワークスペース",
workspaceDefault: "デフォルト",
workspaceRescan: "リポジトリを再スキャン",
workspaceCustom: "別のパス…",
previousPage: "前のページ",
nextPage: "次のページ",
roles: {

View File

@@ -165,6 +165,10 @@ export const ko: Translations = {
failedToDeleteSelected: "선택한 세션 삭제에 실패했습니다",
resumeInChat: "채팅에서 다시 시작",
newChat: "새 채팅",
workspace: "작업 공간",
workspaceDefault: "기본값",
workspaceRescan: "저장소 다시 검색",
workspaceCustom: "다른 경로…",
previousPage: "이전 페이지",
nextPage: "다음 페이지",
roles: {

View File

@@ -165,6 +165,10 @@ export const pt: Translations = {
failedToDeleteSelected: "Falha ao eliminar as sessões selecionadas",
resumeInChat: "Retomar no Chat",
newChat: "Novo chat",
workspace: "área de trabalho",
workspaceDefault: "Padrão",
workspaceRescan: "Reexaminar repositórios",
workspaceCustom: "Outro caminho…",
previousPage: "Página anterior",
nextPage: "Página seguinte",
roles: {

View File

@@ -165,6 +165,10 @@ export const ru: Translations = {
failedToDeleteSelected: "Не удалось удалить выбранные сессии",
resumeInChat: "Продолжить в чате",
newChat: "Новый чат",
workspace: "рабочая папка",
workspaceDefault: "По умолчанию",
workspaceRescan: "Пересканировать репозитории",
workspaceCustom: "Другой путь…",
previousPage: "Предыдущая страница",
nextPage: "Следующая страница",
roles: {

View File

@@ -165,6 +165,10 @@ export const tr: Translations = {
failedToDeleteSelected: "Seçilen oturumlar silinemedi",
resumeInChat: "Sohbette Devam Et",
newChat: "Yeni sohbet",
workspace: "çalışma alanı",
workspaceDefault: "Varsayılan",
workspaceRescan: "Depoları yeniden tara",
workspaceCustom: "Başka yol…",
previousPage: "Önceki sayfa",
nextPage: "Sonraki sayfa",
roles: {

View File

@@ -208,6 +208,10 @@ export interface Translations {
failedToDeleteSelected: string;
resumeInChat: string;
newChat: string;
workspace: string;
workspaceDefault: string;
workspaceRescan: string;
workspaceCustom: string;
previousPage: string;
nextPage: string;
roles: {

View File

@@ -165,6 +165,10 @@ export const uk: Translations = {
failedToDeleteSelected: "Не вдалося видалити вибрані сесії",
resumeInChat: "Продовжити в чаті",
newChat: "Новий чат",
workspace: "робоча папка",
workspaceDefault: "За замовчуванням",
workspaceRescan: "Пересканувати репозиторії",
workspaceCustom: "Інший шлях…",
previousPage: "Попередня сторінка",
nextPage: "Наступна сторінка",
roles: {

View File

@@ -165,6 +165,10 @@ export const zhHant: Translations = {
failedToDeleteSelected: "刪除所選工作階段失敗",
resumeInChat: "在對話中繼續",
newChat: "新對話",
workspace: "工作區",
workspaceDefault: "預設",
workspaceRescan: "重新掃描儲存庫",
workspaceCustom: "其他路徑…",
previousPage: "上一頁",
nextPage: "下一頁",
roles: {

View File

@@ -163,6 +163,10 @@ export const zh: Translations = {
failedToDeleteSelected: "删除所选会话失败",
resumeInChat: "在对话中继续",
newChat: "新对话",
workspace: "工作区",
workspaceDefault: "默认",
workspaceRescan: "重新扫描仓库",
workspaceCustom: "其他路径…",
previousPage: "上一页",
nextPage: "下一页",
roles: {

View File

@@ -462,6 +462,20 @@ export const api = {
fetchJSON<SessionInfo>(
appendProfileParam(`/api/sessions/${encodeURIComponent(id)}`, profile),
),
/**
* Directories a FRESH dashboard chat may start in: the profile's explicit
* projects plus discovered git repos (session-derived + scanned). ``scan``
* asks the host to rescan its discovery roots first (headless installs have
* no Desktop to populate the cache).
*/
getChatWorkspaces: (profile = getManagementProfile(), scan = false) =>
fetchJSON<ChatWorkspacesResponse>(
appendQueryParam(
appendProfileParam("/api/chat/workspaces", profile),
"scan",
scan ? "1" : undefined,
),
),
getSessionLatestDescendant: (id: string, profile = getManagementProfile()) =>
fetchJSON<SessionLatestDescendantResponse>(
appendProfileParam(
@@ -2038,6 +2052,31 @@ export interface DiskPressureStatus {
used_percent?: number | null;
}
export interface ChatWorkspaceProject {
id: string;
slug: string;
name: string;
primary_path: string | null;
archived: boolean;
folders: Array<{ path: string; label: string | null; is_primary: boolean }>;
}
export interface ChatWorkspaceRepo {
root: string;
label: string;
sessions: number;
last_active: number;
}
export interface ChatWorkspacesResponse {
projects: ChatWorkspaceProject[];
repos: ChatWorkspaceRepo[];
/** Where a fresh chat lands when no workspace is picked. */
default_cwd: string;
home: string;
scan_enabled: boolean;
}
export interface SessionInfo {
id: string;
source: string | null;

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import type { ChatWorkspacesResponse } from "@/lib/api";
import { abbreviateHomePath, workspaceOptions } from "@/lib/chat-workspaces";
const base: ChatWorkspacesResponse = {
projects: [],
repos: [],
default_cwd: "/home/u/.hermes",
home: "/home/u",
scan_enabled: true,
};
describe("workspaceOptions", () => {
it("lists project folders first, then repos by recency, deduped on path", () => {
const data: ChatWorkspacesResponse = {
...base,
projects: [
{
id: "p1",
slug: "site",
name: "Site",
primary_path: "/home/u/code/site",
archived: false,
folders: [
{ path: "/home/u/code/site", label: null, is_primary: true },
{ path: "/home/u/code/site-infra", label: "infra", is_primary: false },
],
},
{
id: "p2",
slug: "old",
name: "Old",
primary_path: "/home/u/code/old",
archived: true,
folders: [{ path: "/home/u/code/old", label: null, is_primary: true }],
},
],
repos: [
{ root: "/home/u/code/site", label: "site", sessions: 3, last_active: 50 },
{ root: "/home/u/code/lib", label: "lib", sessions: 1, last_active: 10 },
{ root: "/home/u/code/app", label: "app", sessions: 2, last_active: 90 },
],
};
expect(workspaceOptions(data)).toEqual([
{ value: "/home/u/code/site", label: "Site · ~/code/site" },
{ value: "/home/u/code/site-infra", label: "Site · infra" },
{ value: "/home/u/code/app", label: "app ~/code/app" },
{ value: "/home/u/code/lib", label: "lib ~/code/lib" },
]);
});
});
describe("abbreviateHomePath", () => {
it("collapses the home prefix only on a path boundary", () => {
expect(abbreviateHomePath("/home/u/code/x", "/home/u")).toBe("~/code/x");
expect(abbreviateHomePath("/home/u", "/home/u/")).toBe("~");
expect(abbreviateHomePath("/home/user2/code", "/home/u")).toBe("/home/user2/code");
});
});

View File

@@ -0,0 +1,81 @@
/**
* Workspace picker plumbing for the dashboard Chat tab: which host directory a
* FRESH chat starts in (`/api/pty?cwd=`), persisted per management profile so
* a phone remembers the repo it drives.
*/
import type { ChatWorkspacesResponse } from "@/lib/api";
export interface WorkspaceOption {
value: string;
label: string;
}
const STORAGE_PREFIX = "hermes-chat-workspace:";
export function workspaceStorageKey(profile?: string): string {
return `${STORAGE_PREFIX}${profile ?? ""}`;
}
export function readStoredWorkspace(profile?: string): string {
try {
return localStorage.getItem(workspaceStorageKey(profile)) ?? "";
} catch {
return "";
}
}
export function writeStoredWorkspace(profile: string | undefined, cwd: string): void {
try {
const key = workspaceStorageKey(profile);
if (cwd) localStorage.setItem(key, cwd);
else localStorage.removeItem(key);
} catch {
// Storage unavailable (private mode / quota): the pick still applies this session.
}
}
/** `~`-abbreviate a host path for compact display. */
export function abbreviateHomePath(path: string, home: string): string {
const h = home.replace(/[\\/]+$/, "");
if (!h) return path;
if (path === h) return "~";
const sep = path.startsWith(h + "/") ? "/" : path.startsWith(h + "\\") ? "\\" : null;
return sep ? `~${sep}${path.slice(h.length + 1)}` : path;
}
/**
* Flatten the workspaces payload into picker rows: explicit project folders
* first (a multi-folder project contributes one row per folder), then
* discovered repos not already covered by a project folder, most recently
* active first. Deduped on the path.
*/
export function workspaceOptions(data: ChatWorkspacesResponse): WorkspaceOption[] {
const seen = new Set<string>();
const out: WorkspaceOption[] = [];
const push = (value: string, label: string) => {
if (!value || seen.has(value)) return;
seen.add(value);
out.push({ value, label });
};
for (const project of data.projects) {
if (project.archived) continue;
const folders = project.folders.length
? project.folders
: project.primary_path
? [{ path: project.primary_path, label: null, is_primary: true }]
: [];
for (const folder of folders) {
const suffix =
folders.length > 1
? ` · ${folder.label || abbreviateHomePath(folder.path, data.home)}`
: "";
push(folder.path, `${project.name}${suffix}`);
}
}
const repos = [...data.repos].sort((a, b) => b.last_active - a.last_active);
for (const repo of repos) {
push(repo.root, `${repo.label} ${abbreviateHomePath(repo.root, data.home)}`);
}
return out;
}

View File

@@ -35,6 +35,7 @@ import { ChatSessionList } from "@/components/ChatSessionList";
import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
import { api } from "@/lib/api";
import { readStoredWorkspace, writeStoredWorkspace } from "@/lib/chat-workspaces";
import { latchChatActivation } from "@/lib/chat-activation";
import { copyTextToClipboard } from "@/lib/clipboard";
import { normalizeSessionTitle } from "@/lib/chat-title";
@@ -383,6 +384,27 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// management profile. Changing it remounts the terminal (key below /
// effect dep) so the user explicitly starts a fresh scoped session.
const { profile: scopedProfile } = useProfileScope();
// Workspace a FRESH chat starts in (`/api/pty?cwd=`), persisted per
// management profile (a phone remembers the repo it drives). The connect
// effect reads storage directly, so changing the picker never respawns the
// live PTY: it applies on the next "New chat".
const [workspaceCwd, setWorkspaceCwdState] = useState(() =>
readStoredWorkspace(scopedProfile),
);
const setWorkspaceCwd = useCallback(
(next: string) => {
writeStoredWorkspace(scopedProfile, next);
setWorkspaceCwdState(next);
},
[scopedProfile],
);
// Profile switch: show that profile's remembered workspace (state, not an
// effect, so no cascading render).
const [workspaceProfile, setWorkspaceProfile] = useState(scopedProfile);
if (workspaceProfile !== scopedProfile) {
setWorkspaceProfile(scopedProfile);
setWorkspaceCwdState(readStoredWorkspace(scopedProfile));
}
const channel = useMemo(
() => generateChannelId(`${resumeParam ?? ""}\0${scopedProfile}`),
[resumeParam, scopedProfile],
@@ -1228,6 +1250,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
const params: Record<string, string> = { channel };
if (resumeParam) params.resume = resumeParam;
if (forceFresh) params.fresh = "1";
// Picked workspace: only meaningful for a fresh chat (a resumed session
// keeps its own cwd); the server validates the directory exists.
const pickedWorkspace = resumeParam ? "" : readStoredWorkspace(scopedProfile);
if (pickedWorkspace) params.cwd = pickedWorkspace;
// Keep-alive identity: reattach to this tab's living PTY across
// refresh/transient drops. A forced-fresh start rotates the token so
// the previous keep-alive PTY is not reattached (registry reaps it).
@@ -1868,6 +1894,8 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
profile={scopedProfile}
onPicked={closeMobilePanel}
onNewChat={startFreshDashboardChat}
workspaceCwd={workspaceCwd}
onWorkspaceChange={setWorkspaceCwd}
/>
</div>
</div>
@@ -2077,6 +2105,8 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
activeSessionId={resumeParam}
profile={scopedProfile}
onNewChat={startFreshDashboardChat}
workspaceCwd={workspaceCwd}
onWorkspaceChange={setWorkspaceCwd}
/>
</div>
</div>