mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
* feat(chat): Chat V2 — sidebar entry + main-area page Replace the floating drawer + FAB with a first-class workspace route `/:slug/chat`. Sidebar gets a single `Chat` entry under Inbox with an unread dot; session history lives inside the Chat tab via a popover rather than leaking into the global sidebar (keeps Multica's "nouns in the nav" semantic — Inbox / Issues / Projects are work objects, Chat is a tool). - Add `paths.workspace(slug).chat()` + update link-handler route set. - New `ChatPage` view with PageHeader, history popover, centered messages/composer column, and empty-state starter prompts. - Delete `ChatWindow`, `ChatFab`, resize helpers, and standalone `ChatSessionHistory` (history now embedded in the popover). - Drop `isOpen`/`toggle`/`showHistory`/resize fields from `useChatStore` — the page is a route now, not an overlay. - Wire the new `/chat` route on web (App Router) and desktop (react-router + tab-store icon mapping). Addresses MUL-1322. * fix(chat): align composer width with message column The ChatPage wrapper added px-4 on top of ChatInput's own px-5, making the composer 32px narrower than the messages column. Drop the outer px-4 so both share the same max-w-3xl outer + px-5 inner padding provided by ChatMessageList / ChatInput. * fix(chat): taller default composer (~3 lines visible, 8 max) min-h 4rem → 7rem, max-h 10rem → 15rem. Empty state previously showed only 1 text row after pb-9 for the action bar; raise the floor so there's visible writing room and lift the ceiling so a longer draft can grow before scrolling kicks in. * fix(chat): restore anchor + in-flight indicator + cold-start session restore Three issues surfaced by review: 1. ContextAnchorButton always disabled on /:slug/chat — useRouteAnchorCandidate only matches issue/project/inbox pathnames, so moving chat to its own route dropped 'bring the page I was on into the conversation'. Track the last anchor-eligible location globally (new useAnchorTracker mounted in AppSidebar + lastAnchorLocation on useChatStore) and substitute it when on /chat. 2. No global 'Multica is working' cue after ChatFab deletion. Subscribe the sidebar Chat entry to pendingChatTasksOptions and swap the unread dot for a spinner while any chat task is in flight. 3. ChatPage restore effect latched didRestoreRef before the sessions query resolved, so cold-start direct nav to /chat landed on the empty state even when the server had an active session. Wait for isSuccess before locking the ref. * fix(chat): clear lastAnchorLocation on workspace rehydration The pathname captured in workspace A would otherwise be reused against workspace B's wsId, triggering a cross-workspace issue/project fetch and silently leaking anchor context into chat messages. --------- Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
182 lines
6.6 KiB
TypeScript
182 lines
6.6 KiB
TypeScript
import { create } from "zustand";
|
|
import type { StorageAdapter } from "../types";
|
|
import { getCurrentSlug, registerForWorkspaceRehydration } from "../platform/workspace-storage";
|
|
import { createLogger } from "../logger";
|
|
|
|
const logger = createLogger("chat.store");
|
|
|
|
const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
|
|
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
|
|
/** Drafts are stored as one JSON blob per workspace: { [sessionId]: text }. */
|
|
const DRAFTS_KEY = "multica:chat:drafts";
|
|
/** Placeholder sessionId for a chat that hasn't been created yet. */
|
|
export const DRAFT_NEW_SESSION = "__new__";
|
|
/** Focus mode is a personal preference — global across workspaces/sessions. */
|
|
const FOCUS_MODE_KEY = "multica:chat:focusMode";
|
|
|
|
function readDrafts(storage: StorageAdapter, key: string): Record<string, string> {
|
|
const raw = storage.getItem(key);
|
|
if (!raw) return {};
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function writeDrafts(storage: StorageAdapter, key: string, drafts: Record<string, string>) {
|
|
// Prune empty entries so the blob doesn't grow unbounded.
|
|
const pruned: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(drafts)) {
|
|
if (v) pruned[k] = v;
|
|
}
|
|
if (Object.keys(pruned).length === 0) {
|
|
storage.removeItem(key);
|
|
} else {
|
|
storage.setItem(key, JSON.stringify(pruned));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Kept as a public type because existing consumers (chat-message-list,
|
|
* views/chat types) import it. Items themselves no longer live in the
|
|
* store — they flow through the React Query cache keyed by task id.
|
|
*/
|
|
export interface ChatTimelineItem {
|
|
seq: number;
|
|
type: "tool_use" | "tool_result" | "thinking" | "text" | "error";
|
|
tool?: string;
|
|
content?: string;
|
|
input?: Record<string, unknown>;
|
|
output?: string;
|
|
}
|
|
|
|
/**
|
|
* A derived "where I am" pointer — not stored, recomputed each render from
|
|
* the current route + react-query cache. The type is exported because
|
|
* consumers (buildAnchorMarkdown, chip props) share the same shape.
|
|
*/
|
|
export interface ContextAnchor {
|
|
type: "issue" | "project";
|
|
/** UUID for `issue`, UUID for `project`. */
|
|
id: string;
|
|
/** Human-readable label: issue identifier (MUL-1) or project title. */
|
|
label: string;
|
|
/** Optional secondary text — issue title for issue anchors. */
|
|
subtitle?: string;
|
|
}
|
|
|
|
export interface ChatState {
|
|
activeSessionId: string | null;
|
|
selectedAgentId: string | null;
|
|
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
|
|
inputDrafts: Record<string, string>;
|
|
/**
|
|
* When on, the chat tracks whatever issue/project/inbox-item the user is
|
|
* looking at and prepends it to outgoing messages. Persisted globally so
|
|
* the preference survives workspace switches and reloads.
|
|
*/
|
|
focusMode: boolean;
|
|
/**
|
|
* Last location where a context anchor could be derived (issue/project/inbox).
|
|
* Updated globally by useAnchorTracker; used as a fallback for the Chat page
|
|
* which is its own route and therefore has no anchor of its own.
|
|
* Not persisted — resets per session; focus mode itself persists.
|
|
*/
|
|
lastAnchorLocation: { pathname: string; search: string } | null;
|
|
setActiveSession: (id: string | null) => void;
|
|
setSelectedAgentId: (id: string) => void;
|
|
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
|
|
setInputDraft: (sessionId: string, draft: string) => void;
|
|
clearInputDraft: (sessionId: string) => void;
|
|
setFocusMode: (on: boolean) => void;
|
|
setLastAnchorLocation: (loc: { pathname: string; search: string } | null) => void;
|
|
}
|
|
|
|
export interface ChatStoreOptions {
|
|
storage: StorageAdapter;
|
|
}
|
|
|
|
export function createChatStore(options: ChatStoreOptions) {
|
|
const { storage } = options;
|
|
|
|
const wsKey = (base: string) => {
|
|
const slug = getCurrentSlug();
|
|
return slug ? `${base}:${slug}` : base;
|
|
};
|
|
|
|
const store = create<ChatState>((set, get) => ({
|
|
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
|
|
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
|
|
inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)),
|
|
focusMode: storage.getItem(FOCUS_MODE_KEY) === "true",
|
|
lastAnchorLocation: null,
|
|
setLastAnchorLocation: (loc) => set({ lastAnchorLocation: loc }),
|
|
setActiveSession: (id) => {
|
|
logger.info("setActiveSession", { from: get().activeSessionId, to: id });
|
|
if (id) {
|
|
storage.setItem(wsKey(SESSION_STORAGE_KEY), id);
|
|
} else {
|
|
storage.removeItem(wsKey(SESSION_STORAGE_KEY));
|
|
}
|
|
set({ activeSessionId: id });
|
|
},
|
|
setSelectedAgentId: (id) => {
|
|
logger.info("setSelectedAgentId", { from: get().selectedAgentId, to: id });
|
|
storage.setItem(wsKey(AGENT_STORAGE_KEY), id);
|
|
set({ selectedAgentId: id });
|
|
},
|
|
setInputDraft: (sessionId, draft) => {
|
|
// Debug level — onUpdate fires on every keystroke.
|
|
logger.debug("setInputDraft", { sessionId, length: draft.length });
|
|
const next = { ...get().inputDrafts, [sessionId]: draft };
|
|
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
|
|
set({ inputDrafts: next });
|
|
},
|
|
setFocusMode: (on) => {
|
|
logger.info("setFocusMode", { to: on });
|
|
if (on) storage.setItem(FOCUS_MODE_KEY, "true");
|
|
else storage.removeItem(FOCUS_MODE_KEY);
|
|
set({ focusMode: on });
|
|
},
|
|
clearInputDraft: (sessionId) => {
|
|
const current = get().inputDrafts;
|
|
if (!(sessionId in current)) {
|
|
logger.debug("clearInputDraft skipped (no draft)", { sessionId });
|
|
return;
|
|
}
|
|
logger.info("clearInputDraft", { sessionId });
|
|
const next = { ...current };
|
|
delete next[sessionId];
|
|
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
|
|
set({ inputDrafts: next });
|
|
},
|
|
}));
|
|
|
|
registerForWorkspaceRehydration(() => {
|
|
const nextSession = storage.getItem(wsKey(SESSION_STORAGE_KEY));
|
|
const nextAgent = storage.getItem(wsKey(AGENT_STORAGE_KEY));
|
|
const nextDrafts = readDrafts(storage, wsKey(DRAFTS_KEY));
|
|
logger.info("workspace rehydration", {
|
|
prevSession: store.getState().activeSessionId,
|
|
nextSession,
|
|
prevAgent: store.getState().selectedAgentId,
|
|
nextAgent,
|
|
draftCount: Object.keys(nextDrafts).length,
|
|
});
|
|
// lastAnchorLocation is not persisted — reset it here so a pathname
|
|
// captured in the previous workspace can't be reused against the new
|
|
// workspace's wsId (would trigger a cross-workspace issue/project fetch
|
|
// and silently leak context into chat messages).
|
|
store.setState({
|
|
activeSessionId: nextSession,
|
|
selectedAgentId: nextAgent,
|
|
inputDrafts: nextDrafts,
|
|
lastAnchorLocation: null,
|
|
});
|
|
});
|
|
|
|
return store;
|
|
}
|