Files
multica/packages/core/paths/consistency.test.ts
Jiayuan Zhang 35aca57939 feat(chat): Chat V2 — sidebar entry + main-area page (#1580)
* 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>
2026-04-24 01:46:37 +08:00

96 lines
3.3 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { paths, isGlobalPath } from "./paths";
import { RESERVED_SLUGS } from "./reserved-slugs";
// C4 — link-handler's WORKSPACE_ROUTE_SEGMENTS must match paths.workspace's
// parameterless method names. We can't import WORKSPACE_ROUTE_SEGMENTS here
// because link-handler is in packages/views (no inverse import allowed), so
// we hardcode the expected list and assert paths.workspace produces the same
// keys. If you change either, BOTH need to be updated — the test catches drift.
describe("paths.workspace() shape", () => {
it("exposes the expected parameterless workspace route methods", () => {
const ws = paths.workspace("__probe__");
const parameterlessRoutes = Object.entries(ws)
.filter(([, fn]) => typeof fn === "function" && fn.length === 0)
.map(([key]) => key);
expect(new Set(parameterlessRoutes)).toEqual(
new Set([
"root",
"issues",
"projects",
"autopilots",
"agents",
"inbox",
"chat",
"myIssues",
"runtimes",
"skills",
"settings",
]),
);
});
it("each parameterless route emits /{slug}/{segment}", () => {
const ws = paths.workspace("acme");
// Check that none of the parameterless paths embed a leaked literal
// and that their second URL segment matches the method name's kebab-case.
const expectedSegments: Array<[string, string]> = [
["issues", "issues"],
["projects", "projects"],
["autopilots", "autopilots"],
["agents", "agents"],
["inbox", "inbox"],
["chat", "chat"],
["myIssues", "my-issues"],
["runtimes", "runtimes"],
["skills", "skills"],
["settings", "settings"],
];
const wsAsAny = ws as unknown as Record<string, () => string>;
for (const [method, segment] of expectedSegments) {
const fn = wsAsAny[method];
expect(typeof fn).toBe("function");
expect(fn!()).toBe(`/acme/${segment}`);
}
});
});
// C5 — invariants between the global/reserved lists.
describe("global path / reserved slug consistency", () => {
// If a path is "global" (never workspace-scoped), the slug name underlying it
// must be reserved — otherwise a user could create a workspace with that slug
// and shadow the global route's URL space.
//
// GLOBAL_PREFIXES from paths.ts is private — we re-derive the list from
// probing isGlobalPath. Order matters: keep this list in sync with paths.ts.
const globalPrefixes = [
"/login",
"/logout",
"/signup",
"/workspaces/",
"/invite/",
"/auth/",
];
it("isGlobalPath agrees with the canonical global prefix list", () => {
for (const prefix of globalPrefixes) {
expect(isGlobalPath(prefix)).toBe(true);
}
expect(isGlobalPath("/acme/issues")).toBe(false);
expect(isGlobalPath("/")).toBe(false);
});
it("every global prefix's first path segment is a reserved slug", () => {
for (const prefix of globalPrefixes) {
const firstSegment = prefix.split("/").filter(Boolean)[0];
if (!firstSegment) continue;
expect(
RESERVED_SLUGS.has(firstSegment),
`'${firstSegment}' is a global path prefix but not a reserved slug — ` +
`a workspace could be created with this slug and shadow the global route`,
).toBe(true);
}
});
});