Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions scripts/test/loki-conversation-groups.ts
Original file line number Diff line number Diff line change
@@ -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");
65 changes: 65 additions & 0 deletions scripts/test/loki-suggested-actions.ts
Original file line number Diff line number Diff line change
@@ -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");
8 changes: 4 additions & 4 deletions src/app/(app)/loki/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="app-page app-page-compact app-viewport-pane flex flex-col">
<div className="app-page ui-loki-page app-viewport-pane flex flex-col">
<Suspense fallback={<div className="mx-auto h-full w-full max-w-5xl animate-pulse rounded-lg bg-surface-base" />}>
<LokiWorkspace
initialProjects={seed?.projects}
Expand Down
29 changes: 24 additions & 5 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -3062,11 +3062,30 @@
}

/* ── Loki — conversational command surface ───────────────────────────────── */
/* Start page: one column, composer is the decision. History is a drawer. */
.ui-loki-page {
@apply py-2 sm:py-3 md:py-4;
}
.ui-loki-workspace {
@apply flex min-h-0 flex-1 flex-col gap-2 lg:grid lg:gap-5;
@apply flex min-h-0 flex-1 flex-col;
}
@media (min-width: 64rem) {
.ui-loki-workspace { grid-template-columns: 15rem minmax(0, 1fr); }
.ui-loki-toolbar {
@apply flex shrink-0 items-center gap-1;
}
.ui-loki-toolbar-btn {
@apply relative inline-flex min-h-11 items-center gap-1.5 rounded-lg px-2.5 text-sm text-text-secondary transition-colors hover:bg-surface-raised hover:text-text-primary sm:min-h-9;
}
.ui-loki-toolbar-dot {
@apply absolute right-1 top-1.5 h-1.5 w-1.5 rounded-full bg-accent-primary;
}
.ui-loki-stage {
@apply mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col;
}
.ui-loki-stage-empty {
@apply justify-center gap-3 pb-16 sm:pb-24;
}
.ui-loki-stage-chat {
@apply gap-2;
}
/* Conversation list row (left pane). */
.ui-loki-convo {
Expand Down Expand Up @@ -3150,7 +3169,7 @@
@apply flex min-h-7 flex-wrap items-center gap-1.5;
}
.ui-loki-composer-input {
@apply max-h-60 min-h-20 w-full resize-none bg-transparent px-1 py-1 text-base leading-relaxed text-text-primary placeholder:text-text-tertiary outline-none sm:min-h-24;
@apply max-h-60 min-h-12 w-full resize-none overflow-y-auto bg-transparent px-1 py-1 text-base leading-relaxed text-text-primary placeholder:text-text-tertiary outline-none sm:min-h-14;
}
.ui-loki-composer-actions {
@apply flex items-center justify-between gap-2 border-t border-border-subtle pt-2;
Expand All @@ -3173,7 +3192,7 @@
@apply inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-accent-primary text-white transition-colors hover:opacity-90 disabled:cursor-not-allowed disabled:bg-surface-raised disabled:text-text-muted;
}
.ui-loki-suggest-row {
@apply flex min-w-0 flex-1 gap-1.5 overflow-x-auto pr-1 sm:flex-none sm:justify-end;
@apply flex min-w-0 flex-wrap gap-1.5;
}
.ui-loki-suggest-chip {
@apply inline-flex min-h-9 shrink-0 items-center rounded-full border border-border-subtle bg-surface-raised px-3 text-xs text-text-secondary transition-colors hover:border-border-default hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-45 sm:min-h-8;
Expand Down
125 changes: 90 additions & 35 deletions src/components/loki/Composer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Send, Mic, MicOff, Check, Loader2, Paperclip, X, ImageIcon, FolderKanban, Plus } from "lucide-react";
import { useVoiceInput } from "@/hooks/use-voice-input";
import { getJson } from "@/lib/api/fetch";
Expand All @@ -11,7 +12,11 @@ import {
isImageMime,
type StagedAttachment,
} from "@/lib/loki/attachments";
import { LOKI_SUGGESTED_ACTIONS, fillSuggestedAction } from "@/config/loki-suggested-actions";
import {
composerChips,
fillSuggestedAction,
type LokiComposerChip,
} from "@/config/loki-suggested-actions";
import { ExecutorHonestyChip } from "@/components/executor/ExecutorHonestyChip";
import type { ExecutorHonestyLabel } from "@/lib/executor-honesty";
import type { Attachment, LokiAgent, LokiProject, ModelChoice } from "./types";
Expand All @@ -35,17 +40,24 @@ export function Composer({
onSend,
defaultText = "",
selectedProjects = [],
projectCount = 0,
selectedGoal = null,
onRemoveProject,
onOpenProjects,
dispatchHonesty = null,
}: {
disabled: boolean;
sending: boolean;
onSend: (text: string, choice: ModelChoice, attachments: Attachment[]) => 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;
Expand Down Expand Up @@ -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 (
<div className="ui-loki-composer-wrap">
<div className="relative">
Expand Down Expand Up @@ -260,7 +296,10 @@ export function Composer({
)}
<div className="ui-loki-composer">
<div className="ui-loki-composer-scope-row">
{selectedProjects.length === 0 && onOpenProjects && (
{selectedProjects.length === 0 &&
projectCount > 0 &&
onOpenProjects &&
(text.trim() || !chips.some((chip) => chip.kind === "open_projects")) && (
<button type="button" className="ui-btn-chip" onClick={onOpenProjects}>
<FolderKanban className="h-3.5 w-3.5" /> Project
</button>
Expand Down Expand Up @@ -293,34 +332,50 @@ export function Composer({
)}
</div>

{!text.trim() && (
{!text.trim() && chips.length > 0 && (
<div className="ui-loki-suggest-row">
{suggestedActions.slice(0, 5).map((action) => (
<button
key={action.id}
type="button"
className="ui-loki-suggest-chip"
disabled={disabled || sending}
onClick={() => sendSuggested(action.template)}
title={`Run: ${fillSuggestedAction(action.template, scopedProjectForTemplate)}`}
>
{action.label}
</button>
))}
{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 (
<Link
key={chip.id}
href={chip.href}
className="ui-loki-suggest-chip"
title={title}
>
{chip.label}
</Link>
);
}
return (
<button
key={chip.id}
type="button"
className="ui-loki-suggest-chip"
disabled={disabled || sending}
onClick={() => runChip(chip)}
title={title}
>
{chip.label}
</button>
);
})}
</div>
)}

<textarea
ref={textareaRef}
className="ui-loki-composer-input"
rows={4}
rows={2}
value={text}
disabled={disabled || voice.status === "transcribing"}
placeholder={
voice.status === "recording"
? "Listening…"
: "Ask, dispatch, or paste a screenshot…"
}
placeholder={placeholder}
onChange={(e) => setText(e.target.value)}
onPaste={handlePaste}
onKeyDown={(e) => {
Expand Down Expand Up @@ -416,7 +471,7 @@ export function Composer({
)}
</div>
<div className="ui-loki-composer-submit-row">
<ExecutorHonestyChip honesty={dispatchHonesty} />
{selectedProjects.length > 0 && <ExecutorHonestyChip honesty={dispatchHonesty} />}
<button
type="button"
className="ui-loki-send-btn"
Expand Down
Binary file modified src/components/loki/ConversationList.tsx
Binary file not shown.
Loading
Loading