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
11 changes: 11 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2103,6 +2103,17 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`.
the ordinary cadence without a lens switch; rejected history stays hidden meanwhile. This
follows T3 Code's separation of [transport health and data freshness](https://github.com/pingdotgg/t3code/blob/main/docs/internals/connection-runtime.md):
an open socket alone does not prove the conversation is current. No code or new controls are borrowed.
A store that writes a turn only when it ends (Letta Code's transcript) keeps its turns on screen
while the agent works or waits on the user, and under them a quiet `Live terminal` box
(`.chat-terminal-fallback.is-live`): the pane's own terminal tail, open, read every second,
12 rows high (8 on a phone) and following its newest line inside, so the chat around it never
moves. It goes in the render that adds the written turn, not when the pane's status follows.
Letta's own input box, the status row under it and the tip under its spinner are cut from the
tail by the layout Letta draws (`src/lib/lettaScreen.ts`); a screen in another shape is shown
as it is.
The Codex and Claude apps stream the running turn into the conversation; nothing written exists
to stream here, so this follows Zed's agent panel, which shows a running command's terminal
inline in the thread rather than guessing at its words.

### Work block (`.work-block`, `.work-row`)
- One per run of work in an assistant turn, in the agent's own order as its terminal shows it:
Expand Down
3 changes: 2 additions & 1 deletion changelog.d/letta-native-chat.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
### Added
- Chat shows a Letta Code pane's conversation, read from Letta's own transcript, when herdr's Letta
integration reports it and Letta's own record names the same conversation; otherwise the
terminal's text stays.
terminal's text stays. Letta writes a turn only when it ends, so while it works or waits for you,
Chat shows the live terminal.
2 changes: 1 addition & 1 deletion docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ Every agent herdr runs shows up with its live status, terminal and alerts. The c
| **Hermes** | Native SQLite session store | — use Terminal |
| **Muse Code** | **Linux untested with real Muse.** Native session log, bound to the pane by the session lock Muse writes for its process; panes in one folder each show their own session. macOS and Linux; Windows uses Terminal. See [Muse chat](muse-chat.md). | — use Terminal |
| **Antigravity (agy)** | **Experimental; macOS untested.** Native ledger, selected by the running process’s held conversation lock. Linux native-tested; Windows uses terminal text. See [Agy behavior and limits](agy-chat.md). | — use Terminal |
| **Letta Code** | **Experimental; Linux and macOS.** Read-only native transcript from where the pane's own `letta` process keeps it (`~/.letta/transcripts`, or its `LETTA_CONFIG_DIR` / `LETTA_TRANSCRIPT_ROOT`), when herdr's Letta integration is installed (`herdr integration install letta`, experimental), it names a conversation for the pane, and Letta's own record in the project folder (`.letta/settings.local.json`) names the same one, is older than that `letta` process and is writable by it, and `letta` is still in the folder it started in (for a local-backend conversation, Letta's local store must also name that agent for it). That holds when `letta` resumes the conversation it last had in that folder (`letta`, or `letta --conversation <id>` on that one). After `/new` or `/resume`, a start on another conversation (`--new` included), once `letta` has moved to another folder (its SetWorkingDirectory or EnterWorktree tools), another `letta` writing that record, while another pane runs `letta` in the same folder, with two panes on one conversation, in the home folder, or before the conversation's first turn on this PC ends, Chat uses the terminal's text until `letta` starts again on that conversation. Letta writes a turn when it ends: a turn appears once it is done, and a cancelled one never does. Only turns Letta Code wrote on this PC show. Choose **Letta Code** in **New workspace**, **New tab with agent…** or **New worktree** where `letta` is installed, or run `letta` in a herdr pane's **Terminal**. Windows uses Terminal. | — use Terminal |
| **Letta Code** | **Experimental; Linux and macOS.** Read-only native transcript from where the pane's own `letta` process keeps it (`~/.letta/transcripts`, or its `LETTA_CONFIG_DIR` / `LETTA_TRANSCRIPT_ROOT`), when herdr's Letta integration is installed (`herdr integration install letta`, experimental), it names a conversation for the pane, and Letta's own record in the project folder (`.letta/settings.local.json`) names the same one, is older than that `letta` process and is writable by it, and `letta` is still in the folder it started in (for a local-backend conversation, Letta's local store must also name that agent for it). That holds when `letta` resumes the conversation it last had in that folder (`letta`, or `letta --conversation <id>` on that one). After `/new` or `/resume`, a start on another conversation (`--new` included), once `letta` has moved to another folder (its SetWorkingDirectory or EnterWorktree tools), another `letta` writing that record, while another pane runs `letta` in the same folder, with two panes on one conversation, in the home folder, or before the conversation's first turn on this PC ends, Chat uses the terminal's text until `letta` starts again on that conversation. Letta writes a turn only when it ends, so while `letta` works on a turn or waits for you, Chat shows the live terminal, and the turn joins the conversation once it is done; a cancelled one never does. Only turns Letta Code wrote on this PC show. Choose **Letta Code** in **New workspace**, **New tab with agent…** or **New worktree** where `letta` is installed, or run `letta` in a herdr pane's **Terminal**. Windows uses Terminal. | — use Terminal |
| **GitHub Copilot CLI** | Linux native transcript when the foreground session is verified. See [Copilot setup and limits](copilot-chat.md). | — recorded choices are read-only; answer questions and approvals in Terminal |
| **Anything else** | The terminal's text | — use Terminal |

Expand Down
121 changes: 121 additions & 0 deletions src/components/ChatLiveTurn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, expect, it } from "bun:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";

import { SettingsProvider } from "../lib/settings.ts";
import { liveTurnShown, nextLiveTurnArm, TerminalFallback, turnOnlyInTerminal, type LiveTurnArm } from "./ChatView.tsx";

const render = (live: boolean): string => {
const languages = Object.getOwnPropertyDescriptor(navigator, "languages");
Object.defineProperty(navigator, "languages", { configurable: true, value: ["en"] });
try {
return renderToStaticMarkup(createElement(SettingsProvider, { children: createElement(TerminalFallback, { paneId: "w1:p1", visible: true, live }) }));
} finally {
if (languages) Object.defineProperty(navigator, "languages", languages);
else Reflect.deleteProperty(navigator, "languages");
}
};

/** Renders of one pane: each step is what ChatView holds then, and whether the box shows. */
function run(steps: { watching?: boolean; running: boolean; started: number; settled: number | null; ok?: boolean; written: string }[]): boolean[] {
let arm: LiveTurnArm | null = null;
return steps.map(({ watching = true, running, started, settled, ok = true, written }) => {
arm = nextLiveTurnArm(arm, "w1:p1", watching, running, started, settled === null ? null : { pane: "w1:p1", read: settled, ok }, written);
return liveTurnShown(arm, written);
});
}

describe("a Letta turn still running", () => {
it("is in the terminal only while Letta works or waits on the user: its transcript gets the turn when it ends", () => {
expect(turnOnlyInTerminal("letta-transcript", "working")).toBe(true);
expect(turnOnlyInTerminal("letta-transcript", "blocked")).toBe(true);
expect(turnOnlyInTerminal("letta-transcript", "idle")).toBe(false);
expect(turnOnlyInTerminal("letta-transcript", "done")).toBe(false);
expect(turnOnlyInTerminal("letta-transcript", undefined)).toBe(false);
expect(turnOnlyInTerminal("omo-transcript", "working")).toBe(false);
expect(turnOnlyInTerminal("claude-transcript", "working")).toBe(false);
expect(turnOnlyInTerminal(undefined, "working")).toBe(false);
});

it("goes in the render that adds the written turn, before the pane's status follows", () => {
expect(run([
{ running: true, started: 3, settled: 3, written: "4" },
{ running: true, started: 4, settled: 4, written: "4" },
{ running: true, started: 5, settled: 5, written: "6" },
{ running: false, started: 5, settled: 5, written: "6" },
])).toEqual([true, true, false, false]);
});

it("stays for the running turn when the transcript shown first was older: a cached answer, or a page that was hidden", () => {
expect(run([
{ running: true, started: 0, settled: null, written: "2" },
{ running: true, started: 1, settled: 1, written: "4" },
{ running: true, started: 2, settled: 2, written: "4" },
{ running: true, started: 3, settled: 3, written: "6" },
])).toEqual([true, true, true, false]);
});

it("takes no baseline from a read that started before the turn ran", () => {
expect(run([
{ running: true, started: 7, settled: 6, written: "4" },
{ running: true, started: 8, settled: 7, written: "6" },
{ running: true, started: 8, settled: 8, written: "6" },
{ running: true, started: 9, settled: 9, written: "8" },
])).toEqual([true, true, true, false]);
});

it("stays past the status edge until a read started after it shows the turn, so the chat is never without it", () => {
expect(run([
{ running: true, started: 2, settled: 2, written: "4" },
{ running: true, started: 3, settled: 3, written: "4" },
{ running: false, started: 3, settled: 3, written: "4" },
{ running: false, started: 4, settled: 3, written: "4" },
{ running: false, started: 4, settled: 4, written: "6" },
])).toEqual([true, true, true, true, false]);
});

it("goes after the status edge once a read started after it fails, or shows no turn: a cancelled turn is never written", () => {
expect(run([
{ running: true, started: 2, settled: 2, written: "4" },
{ running: false, started: 2, settled: 2, written: "4" },
{ running: false, started: 3, settled: 3, ok: false, written: "4" },
])).toEqual([true, true, false]);
expect(run([
{ running: true, started: 2, settled: 2, written: "4" },
{ running: false, started: 2, settled: 2, written: "4" },
{ running: false, started: 3, settled: 3, written: "4" },
])).toEqual([true, true, false]);
});

it("arms afresh after the chat was hidden or offline, when a turn may have ended and the next begun unseen", () => {
expect(run([
{ running: true, started: 2, settled: 2, written: "4" },
{ running: true, started: 3, settled: 3, written: "4" },
{ watching: false, running: true, started: 3, settled: 3, written: "4" },
{ running: true, started: 3, settled: 3, written: "4" },
{ running: true, started: 4, settled: 4, written: "6" },
{ running: true, started: 5, settled: 5, written: "8" },
])).toEqual([true, true, false, true, true, false]);
});

it("arms afresh for the next turn and for another pane", () => {
const read = { pane: "w1:p1", read: 2, ok: true };
let arm = nextLiveTurnArm(null, "w1:p1", true, true, 1, read, "4");
arm = nextLiveTurnArm(arm, "w1:p1", true, true, 2, read, "4");
expect(arm?.key).toBe("4");
expect(nextLiveTurnArm(arm, "w1:p2", true, true, 2, read, "9")).toEqual({ pane: "w1:p2", after: 2, key: null, ended: null });
const ended = nextLiveTurnArm(arm, "w1:p1", true, false, 2, read, "4");
expect(nextLiveTurnArm(ended, "w1:p1", true, true, 3, read, "4")).toEqual({ pane: "w1:p1", after: 3, key: null, ended: null });
expect(nextLiveTurnArm(arm, "w1:p1", false, true, 2, read, "6")).toBeNull();
});

it("opens under the turns with its full height from the start, out of the chat's live announcements", () => {
const live = render(true);
expect(live).toContain('class="chat-terminal-fallback is-live" open=""');
expect(live).toMatch(/<pre[^>]*aria-live="off"[^>]*><\/pre>/);
const fallback = render(false);
expect(fallback).toContain('class="chat-terminal-fallback"');
expect(fallback).not.toContain("open=");
expect(fallback).not.toContain("<pre");
});
});
19 changes: 19 additions & 0 deletions src/components/ChatView.css
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,25 @@
.chat-terminal-fallback summary { cursor: pointer; }
.chat-terminal-fallback pre { white-space: pre-wrap; overflow-wrap: anywhere; }

/* the turn a Letta transcript writes only when it ends: a few rows of the terminal's tail, quiet
under the turns, at a fixed height so the chat does not move while it scrolls inside */
.chat-terminal-fallback.is-live summary { font-size: var(--fs-xs); }
.chat-terminal-fallback.is-live pre {
--live-rows: 12;
height: calc(var(--live-rows) * 1.5 * var(--fs-xs));
line-height: 1.5;
overflow-y: auto;
margin: var(--space-1) 0 0;
padding: var(--space-1) var(--space-2);
border-left: 2px solid var(--border);
font-family: var(--font-mono);
font-size: var(--fs-xs);
}

@media (max-width: 480px) {
.chat-terminal-fallback.is-live pre { --live-rows: 8; }
}

/* a user turn opens a new exchange: space separates it from the answer above, not a rule
(the one rule of an exchange closes its folded work) */
.chat-turn-user {
Expand Down
Loading
Loading