Files
platform/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx
T
pastilhas bde59eb74e activity, capabilities, chat history and the locale picker (21 -> 2)
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.
2026-08-23 05:37:04 +00:00

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>
);
};