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
13 changes: 12 additions & 1 deletion apps/identra-desktop/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import SettingsPanel from "./SettingsPanel";
import WorkPanel from "./WorkPanel";
import WorkspaceMenu from "./WorkspaceMenu";
import CommandBar, { MOD_LABEL, type DispatchState } from "./CommandBar";
import ChangesPanel from "./ChangesPanel";
import ConnectionsPanel from "./ConnectionsPanel";
import WallpaperPicker from "./WallpaperPicker";
import { AgentIcon } from "./icons";
Expand Down Expand Up @@ -100,7 +101,7 @@ const DEFAULT_H = 320;
// is the change. Connections is new, and it is not a convenience: it is the only place a grant of
// agent-to-agent access can now be seen. Changes and Review are named in the plan and are not
// built, and an empty tab that says "coming soon" is worse than a column with three honest ones.
type RightMode = "work" | "files" | "connections";
type RightMode = "work" | "files" | "changes" | "connections";

// Whether this workspace has been told its canvas is gone. Kept in the browser's own storage rather
// than in the engine, because it is a fact about what this person has read and not about the
Expand Down Expand Up @@ -1288,6 +1289,15 @@ export default function App() {
>
Files
</button>
<button
data-on={right === "changes"}
onClick={() =>
setRight((cur) => (cur === "changes" ? null : "changes"))
}
title="What the agents have done to this workspace's files"
>
Changes
</button>
<button
data-on={right === "connections"}
onClick={() =>
Expand All @@ -1307,6 +1317,7 @@ export default function App() {
{right === "work" && (
<WorkPanel nodes={nodes} onClose={() => setRight(null)} />
)}
{right === "changes" && <ChangesPanel onClose={() => setRight(null)} />}
{right === "connections" && (
<ConnectionsPanel
nodes={nodes}
Expand Down
164 changes: 164 additions & 0 deletions apps/identra-desktop/frontend/src/ChangesPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// What the agents have done to your working tree.
//
// The gap this closes is embarrassing when you say it plainly: Identra ran four agents editing your
// repository and gave you no way to see what they touched. The task board showed what they claimed,
// the memory showed what they decided, and finding out what they actually changed meant opening a
// terminal and typing `git status` inside the app you opened to avoid doing that.
//
// Grouped by directory, because a flat list of forty paths is a thing to search rather than read,
// and the shape of a change — six files under `src/memory`, one under `docs` — is most of what a
// person wants from a glance at it.
//
// Read-only, and that is a decision rather than a first cut. See the note on `changes()` in
// identra-core: staging without a commit control is half a gesture, and revert is the only thing in
// this app that destroys work no undo brings back. It would have sat one click from a list you scan
// quickly, describing files an agent wrote while you were not watching.
import { useEffect, useState } from "react";
import { useEscape } from "./useEscape";
import { workspaceChanges, type Changes, type FileChange } from "./api";

// The panel polls, because agents write to the tree from their own processes and there is no event
// to subscribe to without inventing one. Slower than the memory poll: a `git status` walks the
// working tree, which is real work on a large repository, and a diff stat that is four seconds old
// has never mattered to anyone.
const POLL_MS = 4000;

type Props = { onClose: () => void };

// What each state contributes to how the row reads. Deleted is the one that has to look different
// at a glance: everything else is work arriving, and that is work leaving.
const MARK: Record<FileChange["state"], string> = {
added: "A",
modified: "M",
deleted: "D",
renamed: "R",
untracked: "?",
};

export default function ChangesPanel({ onClose }: Props) {
useEscape(onClose);
const [changes, setChanges] = useState<Changes | null>(null);
const [error, setError] = useState<string | null>(null);
// Whether the first read has come back. Without it the panel opens saying a repository with
// thirty changed files is clean, then corrects itself a beat later — the same lesson the work
// panel and the file browser both learned, and the empty state is the one moment it is worst to
// be confidently wrong.
const [loaded, setLoaded] = useState(false);

useEffect(() => {
let dropped = false;
const tick = async () => {
try {
const next = await workspaceChanges();
if (dropped) return;
setChanges(next);
setError(null);
} catch (e) {
if (!dropped) setError(String(e));
} finally {
if (!dropped) setLoaded(true);
}
};
void tick();
const timer = window.setInterval(() => void tick(), POLL_MS);
return () => {
dropped = true;
window.clearInterval(timer);
};
}, []);

// Directory to its files, in the order the engine sorted them, so two reads never reshuffle.
const groups = new Map<string, FileChange[]>();
for (const f of changes?.files ?? []) {
const at = f.path.lastIndexOf("/");
const dir = at === -1 ? "" : f.path.slice(0, at);
const list = groups.get(dir);
if (list === undefined) groups.set(dir, [f]);
else list.push(f);
}

return (
<div className="identra-panel">
<div className="identra-panel__head">
<span className="identra-panel__tab" data-on="true">
Changes
</span>
<button
className="identra-panel__close"
onClick={onClose}
title="Close"
>
&times;
</button>
</div>

{changes !== null && (
// Which branch, and whose. An agent given its own checkout is working somewhere else
// entirely, and a diff read as your branch when it is a helper's is the kind of wrong that
// ends with someone committing the wrong thing.
<div className="identra-changes__branch">
<span>{changes.branch ?? "detached HEAD"}</span>
{changes.worktree && (
<span
className="identra-changes__worktree"
title="This checkout is one of Identra's isolated worktrees, not your own branch."
>
isolated worktree
</span>
)}
</div>
)}

<div className="identra-panel__list">
{error !== null && (
<p className="identra-panel__error" role="alert">
{error}
</p>
)}
{error === null && loaded && groups.size === 0 && (
<p className="identra-panel__empty">
Nothing has changed in the working tree.
</p>
)}
{[...groups.entries()].map(([dir, files]) => (
<div className="identra-changes__group" key={dir}>
<div className="identra-changes__dir">{dir === "" ? "." : dir}</div>
{files.map((f) => (
<div
className="identra-changes__row"
key={f.path}
data-state={f.state}
// The full path, because the row shows only the last segment and two files called
// `mod.rs` under different directories are otherwise the same row twice.
title={f.path}
>
<span className="identra-changes__mark">{MARK[f.state]}</span>
<span className="identra-changes__name">
{f.path.slice(f.path.lastIndexOf("/") + 1)}
</span>
{f.staged && (
<span
className="identra-changes__staged"
title="Staged. Identra did not stage it — something in your terminal did."
>
staged
</span>
)}
{/* A binary file gets no numbers rather than a pair of zeroes: git will not diff
it, and "+0 -0" on a 4MB image an agent just wrote reads as nothing happened. */}
{f.added === null || f.removed === null ? (
<span className="identra-changes__binary">binary</span>
) : (
<span className="identra-changes__stat">
<span className="identra-changes__plus">+{f.added}</span>
<span className="identra-changes__minus">−{f.removed}</span>
</span>
)}
</div>
))}
</div>
))}
</div>
</div>
);
}
22 changes: 22 additions & 0 deletions apps/identra-desktop/frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,28 @@ export type Handshake = { facts: number; at: number };
export const busHandshakes = () =>
invoke<Record<string, Handshake>>("bus_handshakes");

// What the agents have done to the working tree. Mirrors `changes.rs` in identra-core.
//
// `added`/`removed` are null for a binary file, which is git saying it will not diff this, and a
// zero there would read as "nothing happened" on a file that entirely changed.
export type FileChange = {
path: string;
added: number | null;
removed: number | null;
state: "added" | "modified" | "deleted" | "renamed" | "untracked";
staged: boolean;
};

export type Changes = {
/// Null on a detached HEAD, which is a state worth showing rather than an error.
branch: string | null;
/// True when this is one of Identra's isolated worktrees rather than the user's own checkout.
worktree: boolean;
files: FileChange[];
};

export const workspaceChanges = () => invoke<Changes>("workspace_changes");

export const memorySearch = (query: string, limit?: number) =>
invoke<Memory[]>("memory_search", { query, limit: limit ?? null });

Expand Down
95 changes: 95 additions & 0 deletions apps/identra-desktop/frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2242,3 +2242,98 @@ body {
.identra-side__handshake-when {
color: var(--state-missing);
}

/* Changes: the branch line, then files grouped by directory. Denser than the file browser, because
this is a list you scan for a shape — six files under src/memory, one under docs — rather than
one you navigate. */
.identra-changes__branch {
display: flex;
align-items: center;
gap: 7px;
padding: 7px 12px;
border-bottom: 1px solid var(--panel-edge);
font-size: 12px;
color: var(--text);
}
/* An agent given its own checkout is working somewhere else entirely, and a diff read as your
branch when it is a helper's is how someone commits the wrong thing. */
.identra-changes__worktree {
padding: 1px 6px;
border-radius: 5px;
background: var(--header);
color: var(--state-setup);
font-size: 10px;
cursor: help;
}
.identra-changes__group {
padding: 4px 0;
}
.identra-changes__dir {
padding: 3px 12px;
font-size: 11px;
color: var(--state-missing);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
direction: rtl;
text-align: left;
}
.identra-changes__row {
display: flex;
align-items: center;
gap: 7px;
padding: 3px 12px 3px 20px;
font-size: 12px;
}
.identra-changes__row:hover {
background: var(--panel);
}
.identra-changes__mark {
width: 10px;
flex: none;
font-family: "Ubuntu Mono", Menlo, Consolas, monospace;
color: var(--state-missing);
}
/* Untracked is the most interesting row in the list — a file an agent made that git would not have
shown you — and deleted is the only one where work is leaving rather than arriving. */
.identra-changes__row[data-state="untracked"] .identra-changes__mark {
color: var(--state-running);
}
.identra-changes__row[data-state="deleted"] .identra-changes__mark {
color: #c01c28;
}
.identra-changes__row[data-state="deleted"] .identra-changes__name {
text-decoration: line-through;
opacity: 0.7;
}
.identra-changes__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.identra-changes__staged {
flex: none;
font-size: 10px;
color: var(--state-missing);
cursor: help;
}
.identra-changes__stat {
flex: none;
display: flex;
gap: 5px;
font-family: "Ubuntu Mono", Menlo, Consolas, monospace;
font-size: 11px;
}
.identra-changes__plus {
color: var(--state-running);
}
.identra-changes__minus {
color: #c01c28;
}
.identra-changes__binary {
flex: none;
font-size: 10px;
color: var(--state-missing);
}
15 changes: 15 additions & 0 deletions apps/identra-desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,20 @@ fn bus_handshakes(
state.bus.handshakes()
}

/// What the agents have done to this workspace's working tree.
///
/// The one question Identra could not answer about itself. Agents run here editing files, and until
/// now the only way to see what they changed was to open a terminal and run `git status` — inside
/// the app whose whole purpose is watching agents work. The board says what they claimed and the
/// memory says what they decided; this is what they did.
///
/// A workspace that is not a repository is an ordinary state, since Identra makes empty ones, so
/// the error is a sentence the panel can print rather than something it has to treat as a fault.
#[tauri::command]
fn workspace_changes(state: State<AppState>) -> Result<identra_core::changes::Changes, String> {
identra_core::changes::changes(&state.dir()).map_err(|e| e.to_string())
}

/// Search what the project has learned. Same ranking the agents get: with a model, by meaning;
/// without one, by words. This is why it goes through the bus opener rather than a bare store.
#[tauri::command]
Expand Down Expand Up @@ -922,6 +936,7 @@ pub fn run() {
board_list,
memory_list,
bus_handshakes,
workspace_changes,
memory_restated,
memory_superseded,
memory_search,
Expand Down
Loading
Loading