Skip to content

Commit 8dbf30d

Browse files
feat: reworked session modal UI and work buttons
1 parent a941658 commit 8dbf30d

28 files changed

Lines changed: 1465 additions & 159 deletions

apps/daemon/src/memory-service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,10 @@ export class MemoryService {
193193
return this.sessions.closeSession(params);
194194
}
195195

196+
async closeStaleSessions(params: Parameters<SessionService["closeStaleSessions"]>[0]) {
197+
return this.sessions.closeStaleSessions(params);
198+
}
199+
196200
async generateSessionSummary(params: Parameters<SessionService["generateSessionSummary"]>[0]) {
197201
return this.sessions.generateSessionSummary(params);
198202
}

apps/daemon/src/rpc-params.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const CONTROL_PLANE_PARAMS: Record<string, readonly string[]> = {
2323
"memory.backup_project": ["projectId"],
2424
"memory.check_semantic_graph_provider": ["projectId"],
2525
"memory.classify_imported_doc": ["projectId", "documentId"],
26+
"memory.close_stale_sessions": ["projectId"],
2627
"memory.commit_import": ["projectId"],
2728
"memory.create_doc": ["projectId", "title", "type", "body"],
2829
"memory.create_project": ["preview"],

apps/daemon/src/rpc.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ async function callMethod(service: MemoryService, method: string, params: Record
9393
return service.updateSessionGraphVisibility(requireParams(params, method));
9494
case "memory.close_session":
9595
return service.closeSession(requireParams(params, method));
96+
case "memory.close_stale_sessions":
97+
return service.closeStaleSessions(requireParams(params, method));
9698
case "memory.generate_session_summary":
9799
return service.generateSessionSummary(requireParams(params, method));
98100
case "memory.generate_session_summaries":

apps/daemon/src/services/project-service.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path from "node:path";
33
import {
44
DEFAULT_ASSISTANT_POLICY,
55
DEFAULT_MEMORY_WRITE_POLICY,
6+
localDayKey,
67
nowIso,
78
type AssistantPolicy,
89
type AssistantRuntimeType,
@@ -102,7 +103,15 @@ export class ProjectService {
102103
(workstream) => workstream.status === "active" || workstream.status === "paused"
103104
);
104105
const workstreams = allOpenWorkstreams.slice(0, 12).map(compactWorkstream);
105-
const recommendedAction = activeSession
106+
// Agents routinely exit without closing, so a session still marked active
107+
// on an earlier local day is abandoned rather than resumable.
108+
// `memory.start_session` closes it at day rollover; until then startup must
109+
// not send the agent back into yesterday's log.
110+
const activeSessionDay = activeSession
111+
? localDayKey(activeSession.updated || activeSession.started)
112+
: "";
113+
const activeSessionIsStale = Boolean(activeSessionDay) && activeSessionDay < localDayKey();
114+
const recommendedAction = activeSession && !activeSessionIsStale
106115
? "resume-active"
107116
: latestSession && project.contextPolicy.startupMode !== "always-start-new-session"
108117
? "resume-latest"
@@ -128,7 +137,9 @@ export class ProjectService {
128137
recommendedAction,
129138
contextReadiness: activeSession || latestSession ? "ready" : "needs-session",
130139
safetyStatus: "clean",
131-
messageForClient: `Resolved Zharwing Memory project ${boundedString(project.name, 160)}.`
140+
messageForClient: activeSessionIsStale
141+
? `Resolved Zharwing Memory project ${boundedString(project.name, 160)}. The open session is from an earlier day and will be closed automatically; start a new session for today's work.`
142+
: `Resolved Zharwing Memory project ${boundedString(project.name, 160)}.`
132143
};
133144
return withStartupRevision(snapshot, params.knownRevision);
134145
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import assert from "node:assert/strict";
2+
import { promises as fs } from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import { test, type TestContext } from "node:test";
6+
import { readSession, writeSession } from "@zharwing/memory-store";
7+
import { MemoryService } from "../memory-service.js";
8+
import { STALE_SESSION_CLOSE_REASON } from "./session-service.js";
9+
10+
test("starting a session auto-closes sessions left active on an earlier day", async (t) => {
11+
const service = new MemoryService({ memoryRoot: await tempMemoryRoot(t) });
12+
const project = await createProject(service, "Auto Close Rollover");
13+
14+
const staleTimestamp = hoursAgo(48);
15+
const yesterday = await service.startSession({
16+
projectId: project.id,
17+
taskTitle: "Yesterday's abandoned work",
18+
agent: "node-test"
19+
});
20+
await backdateSession(yesterday.filePath!, staleTimestamp);
21+
22+
const today = await service.startSession({
23+
projectId: project.id,
24+
taskTitle: "Today's work",
25+
agent: "node-test"
26+
});
27+
assert.equal(today.status, "active");
28+
29+
const sessions = await service.listSessions({ projectId: project.id });
30+
const staleRow = sessions.find((session) => session.id === yesterday.id);
31+
assert.equal(staleRow?.status, "closed");
32+
assert.equal(staleRow?.closedReason, STALE_SESSION_CLOSE_REASON);
33+
// The close is housekeeping, not activity: the stale session keeps its own
34+
// recency so it does not outrank today's session in the list.
35+
assert.equal(staleRow?.updated, staleTimestamp);
36+
assert.equal(sessions[0]?.id, today.id);
37+
38+
// Auto-close still leaves a searchable TLDR behind.
39+
assert.equal(staleRow?.summarySource, "deterministic");
40+
assert.ok(staleRow?.summary);
41+
42+
const active = await service.getActiveSession({ projectId: project.id });
43+
assert.equal(active?.id, today.id);
44+
});
45+
46+
test("start_or_resume starts a new session instead of resuming a previous day's log", async (t) => {
47+
const service = new MemoryService({ memoryRoot: await tempMemoryRoot(t) });
48+
const project = await createProject(service, "Auto Close Resume");
49+
50+
const yesterday = await service.startOrResumeSession({ projectId: project.id, agent: "node-test" });
51+
await backdateSession(yesterday.filePath!, hoursAgo(48));
52+
53+
const resumed = await service.startOrResumeSession({ projectId: project.id, agent: "node-test" });
54+
assert.notEqual(resumed.id, yesterday.id);
55+
assert.equal(resumed.status, "active");
56+
57+
const sameDay = await service.startOrResumeSession({ projectId: project.id, agent: "node-test" });
58+
assert.equal(sameDay.id, resumed.id);
59+
});
60+
61+
test("startup state stops recommending a previous day's session", async (t) => {
62+
const memoryRoot = await tempMemoryRoot(t);
63+
const service = new MemoryService({ memoryRoot });
64+
const project = await createProject(service, "Auto Close Startup");
65+
66+
const session = await service.startSession({
67+
projectId: project.id,
68+
taskTitle: "Left open overnight",
69+
agent: "node-test"
70+
});
71+
72+
const sameDay = await service.getStartupState({ projectId: project.id, workingDirectory: memoryRoot });
73+
if (sameDay.notModified) assert.fail("startup must return a snapshot");
74+
assert.equal(sameDay.recommendedAction, "resume-active");
75+
76+
await backdateSession(session.filePath!, hoursAgo(48));
77+
78+
const nextDay = await service.getStartupState({ projectId: project.id, workingDirectory: memoryRoot });
79+
if (nextDay.notModified) assert.fail("startup must return a snapshot");
80+
assert.equal(nextDay.recommendedAction, "start-new");
81+
});
82+
83+
test("auto-close leaves an already summarized session's TLDR untouched", async (t) => {
84+
const service = new MemoryService({ memoryRoot: await tempMemoryRoot(t) });
85+
const project = await createProject(service, "Auto Close Summary");
86+
87+
const yesterday = await service.startSession({
88+
projectId: project.id,
89+
taskTitle: "Summarized already",
90+
agent: "node-test"
91+
});
92+
const stored = await readSession(yesterday.filePath!);
93+
await writeSession({
94+
...stored,
95+
started: hoursAgo(48),
96+
updated: hoursAgo(48),
97+
summary: "Hand written TLDR.",
98+
summaryGeneratedAt: hoursAgo(48),
99+
summarySource: "manual"
100+
});
101+
102+
await service.startSession({ projectId: project.id, taskTitle: "Next day", agent: "node-test" });
103+
104+
const sessions = await service.listSessions({ projectId: project.id });
105+
const staleRow = sessions.find((session) => session.id === yesterday.id);
106+
assert.equal(staleRow?.status, "closed");
107+
assert.equal(staleRow?.summary, "Hand written TLDR.");
108+
assert.equal(staleRow?.summarySource, "manual");
109+
});
110+
111+
async function createProject(service: MemoryService, projectName: string) {
112+
const preview = await service.prepareProjectCreation({ projectName, createPointerFile: false });
113+
return service.createProject({ preview });
114+
}
115+
116+
async function backdateSession(filePath: string, timestamp: string): Promise<void> {
117+
const session = await readSession(filePath);
118+
await writeSession({ ...session, started: timestamp, updated: timestamp });
119+
}
120+
121+
function hoursAgo(hours: number): string {
122+
return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
123+
}
124+
125+
async function tempMemoryRoot(t: TestContext): Promise<string> {
126+
const memoryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zharwing-daemon-test-"));
127+
t.after(() => fs.rm(memoryRoot, { recursive: true, force: true }));
128+
return memoryRoot;
129+
}

apps/daemon/src/services/session-service.ts

Lines changed: 98 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
import { applyPrivacyGate } from "@zharwing/memory-privacy";
2323
import {
2424
isLoopbackHost,
25+
localDayKey,
26+
nowIso,
2527
type Project,
2628
type Session,
2729
type SessionDetail,
@@ -30,32 +32,29 @@ import {
3032
} from "@zharwing/memory-core";
3133
import { resolveProject } from "./project-resolver.js";
3234

35+
/** Recorded on sessions the daemon closes at day rollover. */
36+
export const STALE_SESSION_CLOSE_REASON =
37+
"Auto-closed at day rollover: the session was still active when work resumed on a later day.";
38+
39+
interface StartSessionParams {
40+
projectId: string;
41+
repoPath?: string;
42+
workingDirectory?: string;
43+
branch?: string;
44+
agent?: string;
45+
client?: string;
46+
taskTitle?: string;
47+
goal?: string;
48+
workstreamIds?: string[];
49+
}
50+
3351
export class SessionService {
3452
constructor(private readonly registry: ProjectRegistry) {}
3553

36-
async startSession(params: {
37-
projectId: string;
38-
repoPath?: string;
39-
workingDirectory?: string;
40-
branch?: string;
41-
agent?: string;
42-
client?: string;
43-
taskTitle?: string;
44-
goal?: string;
45-
workstreamIds?: string[];
46-
}) {
54+
async startSession(params: StartSessionParams) {
4755
const project = await resolveProject(this.registry, params.projectId);
48-
return startSession({
49-
project,
50-
repoPath: params.repoPath || project.repos[0]?.path || process.cwd(),
51-
workingDirectory: params.workingDirectory || process.cwd(),
52-
branch: params.branch,
53-
agent: params.agent,
54-
client: params.client,
55-
taskTitle: params.taskTitle?.trim() || undefined,
56-
goal: params.goal,
57-
workstreamIds: params.workstreamIds
58-
});
56+
await this.autoCloseStaleSessions(project);
57+
return this.createSession(project, params);
5958
}
6059

6160
async startOrResumeSession(params: {
@@ -68,10 +67,13 @@ export class SessionService {
6867
goal?: string;
6968
}) {
7069
const project = await resolveProject(this.registry, params.projectId);
70+
// Runs before the resume check on purpose: yesterday's abandoned session
71+
// must not be handed back as today's active session.
72+
await this.autoCloseStaleSessions(project);
7173
const active = await getActiveSession(project);
7274
if (active && !params.taskTitle) return active;
7375

74-
return this.startSession({
76+
return this.createSession(project, {
7577
projectId: params.projectId,
7678
workingDirectory: params.workingDirectory,
7779
branch: params.branch,
@@ -82,6 +84,79 @@ export class SessionService {
8284
});
8385
}
8486

87+
/**
88+
* Operator-triggered version of the day-rollover sweep, for cleaning up
89+
* sessions abandoned by agents without waiting for the next session start.
90+
*/
91+
async closeStaleSessions(params: { projectId: string }) {
92+
const project = await resolveProject(this.registry, params.projectId);
93+
const closed = await this.autoCloseStaleSessions(project);
94+
return {
95+
projectId: project.id,
96+
closed: closed.length,
97+
sessionIds: closed.map((session) => session.id)
98+
};
99+
}
100+
101+
/**
102+
* Closes sessions left active on an earlier local day. Agents routinely exit
103+
* without an explicit close, so day rollover is treated as the implicit end
104+
* of that day's work; otherwise abandoned sessions stay active forever and
105+
* `getActiveSession` keeps resuming an old log instead of starting a new one.
106+
*
107+
* Summaries are filled in deterministically only: starting a session must
108+
* stay fast and must not depend on a local model being reachable.
109+
*/
110+
async autoCloseStaleSessions(project: Project): Promise<Session[]> {
111+
const today = localDayKey();
112+
const summaries = await listProjectSessionSummaries(project);
113+
const stale = summaries.filter((session) => {
114+
if (session.status !== "active") return false;
115+
const day = localDayKey(session.updated || session.started);
116+
return Boolean(day) && day < today;
117+
});
118+
119+
const closed: Session[] = [];
120+
for (const summary of stale) {
121+
try {
122+
const session = await getSession(project, summary.id);
123+
if (!session) continue;
124+
// Summarized as it will be stored, so the TLDR does not claim the
125+
// session is still active.
126+
const draft = session.summaryGeneratedAt
127+
? undefined
128+
: summarizeSessionMetadataDeterministically({ ...session, status: "closed" });
129+
closed.push(await storageCloseSession({
130+
project,
131+
sessionId: session.id,
132+
summary: draft?.summary,
133+
topics: draft?.topics,
134+
summaryGeneratedAt: draft ? nowIso() : undefined,
135+
summarySource: draft ? "deterministic" : undefined,
136+
closedReason: STALE_SESSION_CLOSE_REASON,
137+
preserveUpdated: true
138+
}));
139+
} catch {
140+
// Housekeeping must never block the session the caller asked for.
141+
}
142+
}
143+
return closed;
144+
}
145+
146+
private async createSession(project: Project, params: StartSessionParams) {
147+
return startSession({
148+
project,
149+
repoPath: params.repoPath || project.repos[0]?.path || process.cwd(),
150+
workingDirectory: params.workingDirectory || process.cwd(),
151+
branch: params.branch,
152+
agent: params.agent,
153+
client: params.client,
154+
taskTitle: params.taskTitle?.trim() || undefined,
155+
goal: params.goal,
156+
workstreamIds: params.workstreamIds
157+
});
158+
}
159+
85160
async listSessions(params: { projectId: string; limit?: number }) {
86161
const project = await resolveProject(this.registry, params.projectId);
87162
const sessions = await listProjectSessionSummaries(project);

apps/desktop/src/components/DataTable.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ export function DataTable({
44
columns,
55
columnLabels,
66
rows,
7+
renderers,
78
selectedRowId,
89
onRowClick,
910
rowActions
1011
}: {
1112
columns: string[];
1213
columnLabels?: Record<string, string>;
1314
rows: any[];
15+
/** Per-column cell renderers; columns without one fall back to the raw value. */
16+
renderers?: Record<string, (row: any) => ReactNode>;
1417
selectedRowId?: string;
1518
onRowClick?: (row: any) => void;
1619
rowActions?: (row: any) => ReactNode;
@@ -31,7 +34,9 @@ export function DataTable({
3134
className={`${onRowClick ? "clickable-row" : ""} ${selectedRowId && row.id === selectedRowId ? "selected-row" : ""}`}
3235
onClick={onRowClick ? () => onRowClick(row) : undefined}
3336
>
34-
{columns.map((column) => <td key={column}>{String(row[column] ?? "")}</td>)}
37+
{columns.map((column) => (
38+
<td key={column}>{renderers?.[column] ? renderers[column](row) : String(row[column] ?? "")}</td>
39+
))}
3540
{rowActions ? (
3641
<td className="table-actions" onClick={(event) => event.stopPropagation()}>
3742
{rowActions(row)}

apps/desktop/src/components/DocumentEditorModal.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { Modal } from "./Modal.js";
3535
import { ToggleGroup } from "./ToggleGroup.js";
3636
import { MarkdownPreview } from "./markdown/MarkdownPreview.js";
3737
import { isLikelyMermaidSource } from "./markdown/MermaidDiagramPreview.js";
38+
import { formatShortDateTime } from "../utils/format.js";
3839

3940
export function DocumentEditorModal({
4041
doc,
@@ -183,7 +184,7 @@ export function DocumentEditorModal({
183184
</div>
184185
</header>
185186
<div className="document-modal-meta">
186-
<KeyValue label="Updated" value={doc.updated} />
187+
<KeyValue label="Updated" value={formatShortDateTime(doc.updated)} />
187188
<KeyValue label="Import profile" value={doc.importProfile || "none"} />
188189
<KeyValue label="Source" value={<code className="path-value">{doc.filePath || "memory"}</code>} />
189190
<KeyValue label="Imported from" value={<code className="path-value">{doc.importSourcePath || "not imported"}</code>} />

0 commit comments

Comments
 (0)