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:
@@ -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"
|
||||
|
||||
159
web/src/components/ChatWorkspacePicker.tsx
Normal file
159
web/src/components/ChatWorkspacePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -165,6 +165,10 @@ export const ja: Translations = {
|
||||
failedToDeleteSelected: "選択したセッションの削除に失敗しました",
|
||||
resumeInChat: "チャットで再開",
|
||||
newChat: "新しいチャット",
|
||||
workspace: "ワークスペース",
|
||||
workspaceDefault: "デフォルト",
|
||||
workspaceRescan: "リポジトリを再スキャン",
|
||||
workspaceCustom: "別のパス…",
|
||||
previousPage: "前のページ",
|
||||
nextPage: "次のページ",
|
||||
roles: {
|
||||
|
||||
@@ -165,6 +165,10 @@ export const ko: Translations = {
|
||||
failedToDeleteSelected: "선택한 세션 삭제에 실패했습니다",
|
||||
resumeInChat: "채팅에서 다시 시작",
|
||||
newChat: "새 채팅",
|
||||
workspace: "작업 공간",
|
||||
workspaceDefault: "기본값",
|
||||
workspaceRescan: "저장소 다시 검색",
|
||||
workspaceCustom: "다른 경로…",
|
||||
previousPage: "이전 페이지",
|
||||
nextPage: "다음 페이지",
|
||||
roles: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -165,6 +165,10 @@ export const ru: Translations = {
|
||||
failedToDeleteSelected: "Не удалось удалить выбранные сессии",
|
||||
resumeInChat: "Продолжить в чате",
|
||||
newChat: "Новый чат",
|
||||
workspace: "рабочая папка",
|
||||
workspaceDefault: "По умолчанию",
|
||||
workspaceRescan: "Пересканировать репозитории",
|
||||
workspaceCustom: "Другой путь…",
|
||||
previousPage: "Предыдущая страница",
|
||||
nextPage: "Следующая страница",
|
||||
roles: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -165,6 +165,10 @@ export const uk: Translations = {
|
||||
failedToDeleteSelected: "Не вдалося видалити вибрані сесії",
|
||||
resumeInChat: "Продовжити в чаті",
|
||||
newChat: "Новий чат",
|
||||
workspace: "робоча папка",
|
||||
workspaceDefault: "За замовчуванням",
|
||||
workspaceRescan: "Пересканувати репозиторії",
|
||||
workspaceCustom: "Інший шлях…",
|
||||
previousPage: "Попередня сторінка",
|
||||
nextPage: "Наступна сторінка",
|
||||
roles: {
|
||||
|
||||
@@ -165,6 +165,10 @@ export const zhHant: Translations = {
|
||||
failedToDeleteSelected: "刪除所選工作階段失敗",
|
||||
resumeInChat: "在對話中繼續",
|
||||
newChat: "新對話",
|
||||
workspace: "工作區",
|
||||
workspaceDefault: "預設",
|
||||
workspaceRescan: "重新掃描儲存庫",
|
||||
workspaceCustom: "其他路徑…",
|
||||
previousPage: "上一頁",
|
||||
nextPage: "下一頁",
|
||||
roles: {
|
||||
|
||||
@@ -163,6 +163,10 @@ export const zh: Translations = {
|
||||
failedToDeleteSelected: "删除所选会话失败",
|
||||
resumeInChat: "在对话中继续",
|
||||
newChat: "新对话",
|
||||
workspace: "工作区",
|
||||
workspaceDefault: "默认",
|
||||
workspaceRescan: "重新扫描仓库",
|
||||
workspaceCustom: "其他路径…",
|
||||
previousPage: "上一页",
|
||||
nextPage: "下一页",
|
||||
roles: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
61
web/src/lib/chat-workspaces.test.ts
Normal file
61
web/src/lib/chat-workspaces.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
81
web/src/lib/chat-workspaces.ts
Normal file
81
web/src/lib/chat-workspaces.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user