diff --git a/scripts/test/loki-conversation-groups.ts b/scripts/test/loki-conversation-groups.ts
new file mode 100644
index 000000000..fad32e553
--- /dev/null
+++ b/scripts/test/loki-conversation-groups.ts
@@ -0,0 +1,59 @@
+import assert from "node:assert/strict";
+import {
+ conversationGroupKey,
+ groupConversations,
+ LOKI_HISTORY_VISIBLE,
+ normalizeConversationTitle,
+ visibleConversationGroups,
+} from "../../src/lib/loki/conversation-groups";
+
+function row(id: string, title: string, projectKeys: string[] = ["fleetcrown"]) {
+ return { id, title, projectKeys };
+}
+
+assert.equal(normalizeConversationTitle("move forward on fleetcrown"), "move-forward");
+assert.equal(normalizeConversationTitle("Move forward"), "move-forward");
+assert.equal(
+ normalizeConversationTitle("Move HamsterCheek toward its active goal: Integrate all feat…"),
+ "move-forward",
+);
+assert.equal(normalizeConversationTitle("code review for kivvi"), "code-review");
+assert.equal(normalizeConversationTitle("Website for restaurants"), "website for restaurants");
+
+assert.equal(
+ conversationGroupKey(row("1", "move forward on fleetcrown", ["fleetcrown"])),
+ conversationGroupKey(row("2", "Move forward", ["FleetCrown"])),
+);
+assert.notEqual(
+ conversationGroupKey(row("1", "move forward on fleetcrown", ["fleetcrown"])),
+ conversationGroupKey(row("2", "move forward on fleetcrown", ["datacat"])),
+);
+
+const mixed = [
+ row("a", "move forward on fleetcrown"),
+ row("b", "move forward on fleetcrown"),
+ row("c", "Website for restaurants", []),
+ row("d", "Move fleetcrown toward its active goal: ship"),
+ row("e", "code review for kivvi", ["kivvi"]),
+];
+const groups = groupConversations(mixed);
+assert.equal(groups.length, 3, "same verb+project collapse; other titles stay");
+const forward = groups.find((g) => g.head.id === "a");
+assert.ok(forward);
+assert.equal(forward!.count, 3);
+assert.equal(groups.find((g) => g.head.id === "c")?.count, 1);
+assert.equal(groups.find((g) => g.head.id === "e")?.count, 1);
+
+const withActive = groupConversations(mixed, "d");
+assert.equal(withActive.find((g) => g.count === 3)?.head.id, "d", "active thread becomes the group head");
+
+const many = Array.from({ length: 20 }, (_, i) => row(String(i), `unique ${i}`, []));
+const capped = visibleConversationGroups(groupConversations(many));
+assert.equal(capped.visible.length, LOKI_HISTORY_VISIBLE);
+assert.equal(capped.hidden, 20 - LOKI_HISTORY_VISIBLE);
+
+const short = visibleConversationGroups(groupConversations(mixed));
+assert.equal(short.hidden, 0);
+assert.equal(short.visible.length, 3);
+
+console.log("✓ loki-conversation-groups tests passed");
diff --git a/scripts/test/loki-suggested-actions.ts b/scripts/test/loki-suggested-actions.ts
new file mode 100644
index 000000000..3201c7fed
--- /dev/null
+++ b/scripts/test/loki-suggested-actions.ts
@@ -0,0 +1,65 @@
+import assert from "node:assert/strict";
+import {
+ composerChips,
+ fillSuggestedAction,
+ LOKI_IMPORT_PROJECT_HREF,
+ LOKI_NEW_PROJECT_HREF,
+ LOKI_SCOPED_CHIPS,
+} from "../../src/config/loki-suggested-actions";
+
+assert.equal(fillSuggestedAction("move forward on {project}", "datacat"), "move forward on datacat");
+assert.equal(fillSuggestedAction("code review for {project}", "kivvi"), "code review for kivvi");
+assert.equal(fillSuggestedAction("move forward on {project}", null), "move forward");
+assert.equal(fillSuggestedAction("next best for {project}", null), "next best");
+assert.equal(fillSuggestedAction("fix types and tests for {project}", null), "fix types and tests");
+
+const none = composerChips({ projectCount: 0, selectedProjects: [] });
+assert.deepEqual(
+ none.map((c) => c.id),
+ ["new_project", "import_project"],
+ "zero projects: start or import — nothing to dispatch",
+);
+assert.equal(none[0].href, LOKI_NEW_PROJECT_HREF);
+assert.equal(none[1].href, LOKI_IMPORT_PROJECT_HREF);
+assert.ok(none.every((c) => c.kind !== "send" || c.chatOnly), "no unscoped dispatch when the fleet is empty");
+
+const unscoped = composerChips({ projectCount: 4, selectedProjects: [] });
+assert.deepEqual(
+ unscoped.map((c) => c.id),
+ ["new_project", "open_project", "attention"],
+ "fleet, no scope: new / open / what needs me",
+);
+assert.equal(unscoped.length, 3);
+assert.ok(!unscoped.some((c) => c.id === "move_forward"), "dispatch chips stay hidden until a project is picked");
+assert.equal(unscoped.find((c) => c.id === "attention")?.chatOnly, true);
+
+const scoped = composerChips({ projectCount: 4, selectedProjects: ["fleetcrown"] });
+assert.deepEqual(
+ scoped.map((c) => c.id),
+ LOKI_SCOPED_CHIPS.map((c) => c.id),
+);
+assert.equal(scoped.length, 3);
+assert.ok(scoped.every((c) => c.kind === "send" && !c.chatOnly));
+
+const withGoal = composerChips({
+ projectCount: 4,
+ selectedProjects: ["HamsterCheek"],
+ selectedGoal: { title: "Integrate the lock" },
+});
+assert.equal(withGoal.length, 3);
+assert.equal(withGoal[0].id, "active_goal");
+assert.ok(withGoal[0].template?.includes("HamsterCheek"));
+assert.ok(withGoal[0].template?.includes("Integrate the lock"));
+assert.deepEqual(
+ withGoal.slice(1).map((c) => c.id),
+ ["quality", "test_and_fix"],
+);
+
+const many = composerChips({ projectCount: 4, selectedProjects: ["a", "b"] });
+assert.deepEqual(
+ many.map((c) => c.id),
+ ["move_forward_many", "quality_many", "test_and_fix_many"],
+);
+assert.ok(many.every((c) => c.template && !c.template.includes("{project}")));
+
+console.log("✓ loki-suggested-actions tests passed");
diff --git a/src/app/(app)/loki/page.tsx b/src/app/(app)/loki/page.tsx
index 5deb11d8e..38bc739de 100644
--- a/src/app/(app)/loki/page.tsx
+++ b/src/app/(app)/loki/page.tsx
@@ -6,15 +6,15 @@ import { prefetchLokiWorkspace } from "@/lib/loki/prefetch";
export const metadata: Metadata = { title: "Loki" };
-// Loki — the conversational command surface (docs/loki-command-surface.md §4).
-// One composer, a conversation list, a project filter; chat routes to Loki and
-// commands dispatch into the project's agent session via the shared resolver.
+// Loki — start a project or continue one. Composer is the only decision;
+// history and project pick live in drawers. Chat vs dispatch is resolved
+// from the message (docs/loki-command-surface.md).
export default async function LokiPage() {
const userId = await getSessionUserId();
const seed = userId ? await prefetchLokiWorkspace(userId) : null;
return (
-
+
}>
void;
+ onSend: (
+ text: string,
+ choice: ModelChoice,
+ attachments: Attachment[],
+ opts?: { chatOnly?: boolean },
+ ) => void;
defaultText?: string;
/** Selected projects from the project pane — visible inside the composer. */
selectedProjects?: string[];
+ projectCount?: number;
selectedGoal?: LokiProject["topGoal"];
onRemoveProject?: (name: string) => void;
onOpenProjects?: () => void;
@@ -212,23 +224,47 @@ export function Composer({
const canSend = (text.trim().length > 0 || attachments.length > 0) && !sending;
const scopedProjectForTemplate = selectedProjects.length === 1 ? selectedProjects[0] : null;
- const suggestedActions = selectedGoal && scopedProjectForTemplate
- ? [
- {
- id: "active_goal",
- label: "Advance top goal",
- template: `Move ${scopedProjectForTemplate} toward its active goal: ${selectedGoal.title}. Inspect the current state and complete the highest-impact next step you can verify.`,
- },
- ...LOKI_SUGGESTED_ACTIONS.filter((action) => action.id !== "next_best"),
- ]
- : LOKI_SUGGESTED_ACTIONS;
+ const chips = composerChips({
+ projectCount,
+ selectedProjects,
+ selectedGoal,
+ });
- const sendSuggested = (template: string) => {
+ useEffect(() => {
+ const el = textareaRef.current;
+ if (!el) return;
+ el.style.height = "auto";
+ el.style.height = `${Math.min(el.scrollHeight, 240)}px`;
+ }, [text]);
+
+ const runChip = (chip: LokiComposerChip) => {
if (disabled || sending) return;
+ if (chip.kind === "open_projects") {
+ onOpenProjects?.();
+ return;
+ }
+ if (chip.kind === "href") return;
+ const template = chip.template ?? "";
const prompt = fillSuggestedAction(template, scopedProjectForTemplate);
- onSend(prompt, parseChoice(choiceKey), []);
+ if (!prompt) return;
+ if (chip.kind === "prefill") {
+ setText(prompt);
+ textareaRef.current?.focus();
+ return;
+ }
+ onSend(prompt, parseChoice(choiceKey), [], chip.chatOnly ? { chatOnly: true } : undefined);
};
+ const placeholder = recording
+ ? "Listening…"
+ : scopedProjectForTemplate
+ ? `Ask or dispatch on ${scopedProjectForTemplate}…`
+ : selectedProjects.length > 1
+ ? `Ask or dispatch on ${selectedProjects.length} projects…`
+ : projectCount === 0
+ ? "Name a new project, or ask anything…"
+ : "Start a project, open one, or ask…";
+
return (
@@ -260,7 +296,10 @@ export function Composer({
)}
- {selectedProjects.length === 0 && onOpenProjects && (
+ {selectedProjects.length === 0 &&
+ projectCount > 0 &&
+ onOpenProjects &&
+ (text.trim() || !chips.some((chip) => chip.kind === "open_projects")) && (
@@ -293,34 +332,50 @@ export function Composer({
)}
- {!text.trim() && (
+ {!text.trim() && chips.length > 0 && (
- {suggestedActions.slice(0, 5).map((action) => (
-
- ))}
+ {chips.map((chip) => {
+ const title =
+ chip.kind === "href"
+ ? chip.label
+ : chip.kind === "open_projects"
+ ? "Choose a project"
+ : fillSuggestedAction(chip.template ?? "", scopedProjectForTemplate);
+ if (chip.kind === "href" && chip.href) {
+ return (
+
+ {chip.label}
+
+ );
+ }
+ return (
+
+ );
+ })}
)}
-
+ {selectedProjects.length > 0 &&
}