The locale picker's labels are now AUTONYMS computed from Intl -- each language named in its own language, so "Português europeu" reads as Portuguese whichever locale the UI is in. That is the convention for a language picker and the useful behaviour: somebody who cannot read the current language still has to find theirs. CapabilityPage interpolated the capability KIND into six strings by lowercasing it in code. "skill" does not lowercase into a Portuguese noun, so kind is a parameter now. What is left is two strings, both deliberate: see the next commit message.
154 lines
8.1 KiB
TypeScript
154 lines
8.1 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { useParams, useNavigate } from 'react-router';
|
|
import type { LayoutNode, SelectedSession } from 'officerdev';
|
|
import { WorkspaceView, chatListPath, cwdFromSplat, useSelectedChatSession } from 'officerdev';
|
|
import { toast } from '@/components/ui/sonner';
|
|
import { useIsMobile } from 'hooks/useIsMobile';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { errorText } from 'helpers/error-text';
|
|
import { useDashboardState } from 'state/useDashboardState';
|
|
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
|
|
import { defaultLayout, hasAppType } from './defaultLayout';
|
|
import { useTranslation } from '@/lib/i18n';
|
|
|
|
// How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in.
|
|
const CHAT_TAIL = 20;
|
|
|
|
type SessionListPageProps = {
|
|
isNew?: boolean;
|
|
};
|
|
|
|
export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
|
const { t } = useTranslation();
|
|
// The group is a path suffix now, not ?cwd= — see officerdev/apps/ChatHistory/chat-routes.ts.
|
|
const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>();
|
|
const groupCwd = cwdFromSplat(splat);
|
|
const [selected, setSelected] = useSelectedChatSession();
|
|
const client = useClient();
|
|
const selectedRef = useRef(selected);
|
|
selectedRef.current = selected;
|
|
// A layout persisted before the chat panels were renamed still names `officerdev/chat`, which no
|
|
// longer resolves; `appTypes` lands anything unknown on the detail panel.
|
|
const workspace = useDashboardState<LayoutNode>('screens/chat', defaultLayout);
|
|
const isMobile = useIsMobile();
|
|
const navigate = useNavigate();
|
|
const mobilePanelId = isMobile && (sessionId || isNew) ? 'chat-detail' : undefined;
|
|
|
|
/*
|
|
Adopt a structural change to this screen's layout.
|
|
|
|
`useDashboardState` seeds its default ONLY when the key is absent, so anyone who has ever opened /chat
|
|
keeps the shape it had then — for good. `appTypes`/`normalizeLayout` does not help: it repairs which app
|
|
a panel RUNS, never the tree.
|
|
|
|
── The test is INVERTED, and getting that wrong is destructive ──
|
|
|
|
This used to read `if (hasAppType(value, 'chat-live')) return;` — adopt the layout that HAS the Live
|
|
panel. Now that the two sidebar panels are merged, `chat-live` is the appType that no longer exists, so
|
|
the same line would refuse to migrate exactly the layouts that need it, and `normalizeLayout` would
|
|
collapse the stranded panel onto `appTypes.fallback` and WRITE THAT BACK. That is not hypothetical: it is
|
|
what made /offscale render three copies of its server picker on 2026-08-17 (`ed3806f9`), and a locked
|
|
screen has no app picker to recover with.
|
|
|
|
So: replace any layout still naming `chat-live`. Terminates because the replacement does not contain it.
|
|
|
|
Replacing outright is safe *here* specifically because the screen is `locked` — its structure is dictated
|
|
by code, and the only thing a user can have contributed is the column sizes, which is a cheap thing to
|
|
lose once.
|
|
*/
|
|
useEffect(() => {
|
|
if (!workspace.isLoaded) return;
|
|
if (!hasAppType(workspace.value, 'chat-live')) return;
|
|
workspace.setValue(defaultLayout);
|
|
}, [workspace.isLoaded, workspace.value, workspace.setValue]);
|
|
|
|
// Retire a legacy `?cwd=`. Nothing reads it any more and nothing writes it, but a refresh re-requests
|
|
// the address bar verbatim — so one left over from before the path-based groups sits there forever,
|
|
// looking like it means something. On a bare /chat it still says which group you wanted, so upgrade
|
|
// it to the path form rather than dropping it; anywhere else the session decides its own directory
|
|
// and the parameter is simply removed. Other params are left alone.
|
|
useEffect(() => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const legacyCwd = params.get('cwd');
|
|
if (legacyCwd === null) return;
|
|
params.delete('cwd');
|
|
const rest = params.toString();
|
|
const canBeUpgraded = !sessionId && !isNew && !groupCwd && legacyCwd.startsWith('/');
|
|
const path = canBeUpgraded ? chatListPath(legacyCwd) : window.location.pathname;
|
|
navigate(`${path}${rest ? `?${rest}` : ''}`, { replace: true });
|
|
}, [sessionId, isNew, groupCwd, navigate]);
|
|
|
|
// Fresh /chat/<id> (deep-link or refresh): all we have is the id. Resolve the session by id — the
|
|
// backend scans project groups — so the cwd picker lands on its real dir AND the chat resumes, exactly
|
|
// as clicking it from the list would. Guarded so it never clobbers an already-loaded selection.
|
|
// Transcripts get extremely long, so we load only the tail window (?limit) and anchor to the bottom;
|
|
// scrolling up lazy-loads older messages. `total`/`offset` tell the chat where the window sits.
|
|
useEffect(() => {
|
|
if (isNew) {
|
|
// A new chat has no transcript to read a cwd from, so the group in the URL is the authority —
|
|
// and it has to be on the selection, because that is what the composer runs in.
|
|
setSelected({ id: `new:${Date.now()}`, cwd: groupCwd });
|
|
return;
|
|
}
|
|
if (!sessionId) return;
|
|
if (selectedRef.current?.id === sessionId && selectedRef.current.resumeSessionId) return;
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const detail = await client.get<ClaudeSessionDetail>(`/chat/sessions/${sessionId}?limit=${CHAT_TAIL}`);
|
|
if (cancelled) return;
|
|
// The session's own cwd rides on the selection rather than being written back into the URL.
|
|
// It used to do both, and the URL copy was the one the composer read — so a deep link ran its
|
|
// first turn in the default dir during the window before this resolve landed.
|
|
setSelected({
|
|
id: sessionId,
|
|
// `officerModel`, not `detail.model`. The latter is what the API served (`claude-opus-5`), which
|
|
// is a different id space from what the picker offers (`claude-code/opus[1m]`) — so it matched no
|
|
// catalogue entry, the selector fell back to whichever model was listed first, and a resumed
|
|
// conversation came back claiming the wrong one. Invisible for as long as the first entry was
|
|
// `opus`; obvious the day `fable` went above it. The server resolves this from the session's
|
|
// stored row, falling back to a family guess. Null means "unknown", and the picker must then
|
|
// leave the selection alone rather than guess — see ModelSelector.
|
|
model: detail.officerModel ?? undefined,
|
|
planMode: detail.planMode,
|
|
resumeSessionId: sessionId,
|
|
initialMessages: detail.messages as unknown as NonNullable<SelectedSession>['initialMessages'],
|
|
total: detail.total,
|
|
initialOffset: detail.offset,
|
|
cwd: detail.cwd,
|
|
title: detail.title,
|
|
partCount: detail.partCount,
|
|
});
|
|
} catch (err) {
|
|
if (cancelled) return;
|
|
// Falling back to a bare id still opens a usable pane, but silently: you get an empty chat and
|
|
// no hint that the transcript could not be read, which is indistinguishable from a new session.
|
|
// Most often the id is stale — the transcript was deleted or pruned out from under the link.
|
|
toast.error(t('chatHistory.loadFailed', { reason: errorText(err, 'not found') }));
|
|
setSelected({ id: sessionId });
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [sessionId, isNew, groupCwd]);
|
|
|
|
return (
|
|
<div className="h-full w-full pt-2">
|
|
<WorkspaceView
|
|
workspace={workspace}
|
|
locked
|
|
appTypes={{ allowed: ['chat-session-list', 'chat-detail'], fallback: 'chat-detail' }}
|
|
mobilePanelId={mobilePanelId}
|
|
onMobilePanelChange={(id) => {
|
|
// Back goes to the group's list, not the default one. On /chat/g/* that group is in the URL;
|
|
// on /chat/<id> it isn't (deliberately — see chat-routes.ts), so fall back to the open
|
|
// session's own directory, which the resolve above put on the selection.
|
|
if (!id) navigate(chatListPath(groupCwd ?? selectedRef.current?.cwd ?? null), { replace: true });
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|