mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
feat(coach): move conversations into a collapsible right panel
The Coach page gets a conversations panel on the right of the thread. From 1280 px it docks beside the thread and slides open and shut; the reading column keeps its width either way and only its centring moves. It is open on a first visit and remembers the last choice per device. Below 1280 px it is a sheet from the right. The toggle sits at the trailing edge of the top bar, through a small page-owned slot the top bar now renders. The panel lists every conversation grouped by recency, with a row menu to rename in place (Enter saves, Escape cancels without closing the panel) and delete with the existing undo toast. The undo window now survives the sheet closing. New chat is the round button at the panel's bottom right, and picking or starting a conversation keeps ?c= in the URL. The gear in the panel header opens quick settings: a popover from md up, a bottom sheet on a phone. It switches the answering provider (chain reordered through the existing route), the model and reasoning level where the person owns them, and shows what the Coach can see. Keys, the Codex sign-in and the fallback chain stay in Settings > AI. The drawer's gear opens the same overlay. The composer's + menu is gone. Settings > Coach links to /coach?settings=data, which opens the overlay on what the Coach can see instead of a rail that no longer exists.
This commit is contained in:
1 parent
1cb50ed21d
commit
12153fe143
33 files changed
+2074
-428
No files matched your search
@@ -24,8 +24,8 @@ import {
|
||||
useCoachConversationHistory,
|
||||
useDeleteCoachConversationWithUndo,
|
||||
} from "@/components/insights/coach-panel/use-coach";
|
||||
import type { CoachConversationDTO } from "@/lib/ai/coach/types";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { groupConversationsByRecency } from "@/lib/insights/coach-conversation-groups";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useAiCapabilityAnswer } from "@/hooks/use-ai-capability";
|
||||
import { useLoadMoreSentinel } from "@/hooks/use-load-more-sentinel";
|
||||
@@ -58,58 +58,6 @@ import { cn } from "@/lib/utils";
|
||||
* wrapper matches the Coach page so the two surfaces share one chrome.
|
||||
*/
|
||||
|
||||
type RecencyGroupId = "today" | "yesterday" | "thisWeek" | "earlier";
|
||||
|
||||
interface RecencyGroup {
|
||||
id: RecencyGroupId;
|
||||
labelKey: string;
|
||||
conversations: CoachConversationDTO[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket conversations by LOCAL calendar recency. `updatedAt` is an ISO
|
||||
* string; comparisons run against local day boundaries so "Today" tracks
|
||||
* the user's own midnight, not UTC.
|
||||
*/
|
||||
function groupByRecency(conversations: CoachConversationDTO[]): RecencyGroup[] {
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
).getTime();
|
||||
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
|
||||
// "This week" = the six days before yesterday (a rolling 7-day window
|
||||
// that already excludes today + yesterday, which have their own groups).
|
||||
const startOfWeek = startOfToday - 6 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const groups: Record<RecencyGroupId, CoachConversationDTO[]> = {
|
||||
today: [],
|
||||
yesterday: [],
|
||||
thisWeek: [],
|
||||
earlier: [],
|
||||
};
|
||||
|
||||
for (const conversation of conversations) {
|
||||
const updated = new Date(conversation.updatedAt).getTime();
|
||||
if (updated >= startOfToday) groups.today.push(conversation);
|
||||
else if (updated >= startOfYesterday) groups.yesterday.push(conversation);
|
||||
else if (updated >= startOfWeek) groups.thisWeek.push(conversation);
|
||||
else groups.earlier.push(conversation);
|
||||
}
|
||||
|
||||
return (
|
||||
[
|
||||
{ id: "today", labelKey: "insights.coach.history.groupToday" },
|
||||
{ id: "yesterday", labelKey: "insights.coach.history.groupYesterday" },
|
||||
{ id: "thisWeek", labelKey: "insights.coach.history.groupThisWeek" },
|
||||
{ id: "earlier", labelKey: "insights.coach.history.groupEarlier" },
|
||||
] as const
|
||||
)
|
||||
.map((g) => ({ ...g, conversations: groups[g.id] }))
|
||||
.filter((g) => g.conversations.length > 0);
|
||||
}
|
||||
|
||||
function CoachConversationsBody({ readOnly }: { readOnly: boolean }) {
|
||||
const { t, locale } = useTranslations();
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
@@ -139,7 +87,7 @@ function CoachConversationsBody({ readOnly }: { readOnly: boolean }) {
|
||||
);
|
||||
const isSearching = debouncedFilter.trim().length > 0;
|
||||
|
||||
const groups = useMemo(() => groupByRecency(visible), [visible]);
|
||||
const groups = useMemo(() => groupConversationsByRecency(visible), [visible]);
|
||||
|
||||
// Same callback-ref pattern as the rail: a `useState` setter (not a plain
|
||||
// `useRef`) so the sentinel hook's `root` re-fires its effect once the
|
||||
|
||||
@@ -81,6 +81,13 @@ import { coachScopeSourceSchema } from "@/lib/ai/coach/types";
|
||||
* this is a prefill, not an auto-send.
|
||||
* Both are ignored once `?c=` or `?doc=` pin an existing/scoped thread — scope
|
||||
* only ever applies to a fresh conversation's first turn.
|
||||
*
|
||||
* The conversations panel on the right of the thread carries the history,
|
||||
* New chat and the settings gear (the composer's `+` menu is gone). Picking a
|
||||
* conversation or starting a new one rewrites `?c=` in place, so a reload
|
||||
* stays on the open thread. `?settings=data` (the link from Settings → Coach)
|
||||
* opens the gear on "What I can see"; the param is consumed on mount and
|
||||
* dropped from the URL so a reload does not reopen the overlay.
|
||||
*/
|
||||
function CoachPageBody() {
|
||||
const searchParams = useSearchParams();
|
||||
@@ -118,6 +125,21 @@ function CoachPageBody() {
|
||||
: null;
|
||||
const seedPrefill = freshChat ? searchParams.get("ask") : null;
|
||||
|
||||
// Read once: the panel consumes it on mount, then the URL loses it.
|
||||
const [openSettingsOnData] = useState(
|
||||
() => searchParams.get("settings") === "data",
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!openSettingsOnData) return;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("settings");
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
url.pathname + url.search,
|
||||
);
|
||||
}, [openSettingsOnData]);
|
||||
|
||||
// #781 — a navigation away mid-reply recorded the interrupted conversation
|
||||
// (see `COACH_INTERRUPTED_STORAGE_KEY` in `use-coach.ts`). Returning to the
|
||||
// Coach reopens that thread — where the persisted `cancelled` marker shows
|
||||
@@ -200,6 +222,7 @@ function CoachPageBody() {
|
||||
// unread coach-initiated exception and the interrupted first-turn
|
||||
// resume (#781, whose conversation id never reached the client).
|
||||
autoOpenMostRecent={hasUnreadCoachMessage || resumeInterruptedLatest}
|
||||
openSettingsOnData={openSettingsOnData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user