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