Skip to content

Commit 8b1226a

Browse files
authored
Merge pull request pingdotgg#2 from ntheile/header-tabs
Add thread header tabs and persistent thread notes
2 parents 67af69c + 74411c8 commit 8b1226a

45 files changed

Lines changed: 2997 additions & 199 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,18 @@ Long term maintainability is a core priority. If you add new functionality, firs
3939
### Live Web Deploy Flow
4040

4141
- When the user asks to make a web change live, rebuild `apps/web` first, then rebuild `apps/server`, then restart `t3code-web.service`.
42+
- Do not build `apps/web` and `apps/server` in parallel for a deploy. The server build copies the current web build into `apps/server/dist/client`, so parallel builds can deploy stale frontend assets even when both builds succeed.
4243
- Use this exact order:
4344
1. `cd apps/web && bun run build`
4445
2. `cd apps/server && bun run build`
4546
3. `systemctl --user restart t3code-web.service`
47+
- In this environment, `bun` may not be on `PATH` inside tool-run shells. If `bun: command not found` appears, prefix commands with `export PATH="$HOME/.bun/bin:$PATH" && ...`.
4648
- After restarting, verify the deploy instead of assuming it worked. Check both:
4749
- service state via `systemctl --user show t3code-web.service -p MainPID -p ExecMainStartTimestamp -p ActiveState -p SubState`
4850
- rebuilt artifact timestamps for `apps/web/dist/index.html`, `apps/server/dist/index.mjs`, and `apps/server/dist/client/index.html`
51+
- The tool wrapper may report `systemctl --user restart t3code-web.service` as `aborted` even when the restart actually succeeded. Treat `systemctl --user show ...`, artifact timestamps, and the live served asset hash as the source of truth.
52+
- When verifying the live frontend, also check the served asset hash directly, for example:
53+
- `curl -s "$APP_URL" | rg -o '/assets/index-[^" ]+\\.(js|css)'`
4954

5055
## Package Roles
5156

apps/server/src/git/Layers/GitCore.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ it.layer(TestLayer)("git integration", (it) => {
262262
const result = yield* listGitBranches({ cwd: tmp });
263263
expect(result.isRepo).toBe(true);
264264
expect(result.hasOriginRemote).toBe(false);
265+
expect(result.originWebUrl).toBeNull();
265266
expect(result.branches.length).toBeGreaterThanOrEqual(1);
266267
}),
267268
);
@@ -276,6 +277,7 @@ it.layer(TestLayer)("git integration", (it) => {
276277
const result = yield* listGitBranches({ cwd: tmp });
277278
expect(result.isRepo).toBe(false);
278279
expect(result.hasOriginRemote).toBe(false);
280+
expect(result.originWebUrl).toBeNull();
279281
expect(result.branches).toEqual([]);
280282
}),
281283
);
@@ -435,6 +437,7 @@ it.layer(TestLayer)("git integration", (it) => {
435437
const firstRemoteIndex = result.branches.findIndex((branch) => branch.isRemote);
436438

437439
expect(result.hasOriginRemote).toBe(true);
440+
expect(result.originWebUrl).toBeNull();
438441
expect(firstRemoteIndex).toBeGreaterThan(0);
439442
expect(result.branches.slice(0, firstRemoteIndex).every((branch) => !branch.isRemote)).toBe(
440443
true,

apps/server/src/git/Layers/GitCore.ts

Lines changed: 44 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Path } from "effect";
22

33
import { GitCommandError } from "../Errors.ts";
4+
import { parseGitHubWebUrlFromRemoteUrl } from "../githubRemote.ts";
45
import { GitService } from "../Services/GitService.ts";
56
import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";
67

@@ -1071,7 +1072,7 @@ export const makeGitCore = Effect.gen(function* () {
10711072
if (localBranchResult.code !== 0) {
10721073
const stderr = localBranchResult.stderr.trim();
10731074
if (stderr.toLowerCase().includes("not a git repository")) {
1074-
return { branches: [], isRepo: false, hasOriginRemote: false };
1075+
return { branches: [], isRepo: false, hasOriginRemote: false, originWebUrl: null };
10751076
}
10761077
return yield* createGitCommandError(
10771078
"GitCore.listBranches",
@@ -1113,33 +1114,42 @@ export const makeGitCore = Effect.gen(function* () {
11131114
),
11141115
);
11151116

1116-
const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] =
1117-
yield* Effect.all(
1118-
[
1119-
executeGit(
1120-
"GitCore.listBranches.defaultRef",
1121-
input.cwd,
1122-
["symbolic-ref", "refs/remotes/origin/HEAD"],
1123-
{
1124-
timeoutMs: 5_000,
1125-
allowNonZeroExit: true,
1126-
},
1127-
),
1128-
executeGit(
1129-
"GitCore.listBranches.worktreeList",
1130-
input.cwd,
1131-
["worktree", "list", "--porcelain"],
1132-
{
1133-
timeoutMs: 5_000,
1134-
allowNonZeroExit: true,
1135-
},
1136-
),
1137-
remoteBranchResultEffect,
1138-
remoteNamesResultEffect,
1139-
branchRecencyPromise,
1140-
],
1141-
{ concurrency: "unbounded" },
1142-
);
1117+
const [
1118+
defaultRef,
1119+
worktreeList,
1120+
remoteBranchResult,
1121+
remoteNamesResult,
1122+
originRemoteUrl,
1123+
branchLastCommit,
1124+
] = yield* Effect.all(
1125+
[
1126+
executeGit(
1127+
"GitCore.listBranches.defaultRef",
1128+
input.cwd,
1129+
["symbolic-ref", "refs/remotes/origin/HEAD"],
1130+
{
1131+
timeoutMs: 5_000,
1132+
allowNonZeroExit: true,
1133+
},
1134+
),
1135+
executeGit(
1136+
"GitCore.listBranches.worktreeList",
1137+
input.cwd,
1138+
["worktree", "list", "--porcelain"],
1139+
{
1140+
timeoutMs: 5_000,
1141+
allowNonZeroExit: true,
1142+
},
1143+
),
1144+
remoteBranchResultEffect,
1145+
remoteNamesResultEffect,
1146+
readConfigValue(input.cwd, "remote.origin.url").pipe(
1147+
Effect.catch(() => Effect.succeed(null)),
1148+
),
1149+
branchRecencyPromise,
1150+
],
1151+
{ concurrency: "unbounded" },
1152+
);
11431153

11441154
const remoteNames =
11451155
remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
@@ -1237,7 +1247,12 @@ export const makeGitCore = Effect.gen(function* () {
12371247

12381248
const branches = [...localBranches, ...remoteBranches];
12391249

1240-
return { branches, isRepo: true, hasOriginRemote: remoteNames.includes("origin") };
1250+
return {
1251+
branches,
1252+
isRepo: true,
1253+
hasOriginRemote: remoteNames.includes("origin"),
1254+
originWebUrl: parseGitHubWebUrlFromRemoteUrl(originRemoteUrl),
1255+
};
12411256
});
12421257

12431258
const createWorktree: GitCoreShape["createWorktree"] = (input) =>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export function parseGitHubWebUrlFromRemoteUrl(url: string | null): string | null {
2+
const trimmed = url?.trim() ?? "";
3+
if (trimmed.length === 0) {
4+
return null;
5+
}
6+
7+
const match =
8+
/^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec(
9+
trimmed,
10+
);
11+
const repositoryNameWithOwner = match?.[1]?.trim() ?? "";
12+
return repositoryNameWithOwner.length > 0
13+
? `https://github.com/${repositoryNameWithOwner}`
14+
: null;
15+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import {
2+
type ThreadNotesDocument as ThreadNotesDocumentRecord,
3+
ThreadNotesDocument,
4+
ThreadNotesGetInput,
5+
} from "@t3tools/contracts";
6+
import { Effect, Layer, Option } from "effect";
7+
import * as SqlClient from "effect/unstable/sql/SqlClient";
8+
import * as SqlSchema from "effect/unstable/sql/SqlSchema";
9+
10+
import { toPersistenceSqlError } from "../Errors.ts";
11+
import { ThreadNotesRepository, type ThreadNotesRepositoryShape } from "../Services/ThreadNotes.ts";
12+
13+
const makeThreadNotesRepository = Effect.gen(function* () {
14+
const sql = yield* SqlClient.SqlClient;
15+
16+
const upsertThreadNotesRow = SqlSchema.void({
17+
Request: ThreadNotesDocument,
18+
execute: (row) =>
19+
sql`
20+
INSERT INTO thread_notes (
21+
thread_id,
22+
notes,
23+
created_at,
24+
updated_at
25+
)
26+
VALUES (
27+
${row.threadId},
28+
${row.notes},
29+
${row.createdAt},
30+
${row.updatedAt}
31+
)
32+
ON CONFLICT (thread_id)
33+
DO UPDATE SET
34+
notes = excluded.notes,
35+
created_at = excluded.created_at,
36+
updated_at = excluded.updated_at
37+
`,
38+
});
39+
40+
const getThreadNotesRow = SqlSchema.findOneOption({
41+
Request: ThreadNotesGetInput,
42+
Result: ThreadNotesDocument,
43+
execute: ({ threadId }) =>
44+
sql`
45+
SELECT
46+
thread_id AS "threadId",
47+
notes,
48+
created_at AS "createdAt",
49+
updated_at AS "updatedAt"
50+
FROM thread_notes
51+
WHERE thread_id = ${threadId}
52+
`,
53+
});
54+
55+
const getByThreadId: ThreadNotesRepositoryShape["getByThreadId"] = (input) =>
56+
getThreadNotesRow(input).pipe(
57+
Effect.mapError(toPersistenceSqlError("ThreadNotesRepository.getByThreadId:query")),
58+
);
59+
60+
const upsert: ThreadNotesRepositoryShape["upsert"] = (input) =>
61+
Effect.gen(function* () {
62+
const existing = yield* getByThreadId({ threadId: input.threadId });
63+
const timestamp = new Date().toISOString();
64+
const row: ThreadNotesDocumentRecord = {
65+
threadId: input.threadId,
66+
notes: input.notes,
67+
createdAt: Option.match(existing, {
68+
onNone: () => timestamp,
69+
onSome: (document) => document.createdAt,
70+
}),
71+
updatedAt: timestamp,
72+
};
73+
yield* upsertThreadNotesRow(row).pipe(
74+
Effect.mapError(toPersistenceSqlError("ThreadNotesRepository.upsert:query")),
75+
);
76+
return row;
77+
});
78+
79+
return {
80+
getByThreadId,
81+
upsert,
82+
} satisfies ThreadNotesRepositoryShape;
83+
});
84+
85+
export const ThreadNotesRepositoryLive = Layer.effect(
86+
ThreadNotesRepository,
87+
makeThreadNotesRepository,
88+
);

apps/server/src/persistence/Migrations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts";
2828
import Migration0014 from "./Migrations/014_RemoteTargetColumns.ts";
2929
import Migration0015 from "./Migrations/015_ExecutionTargets.ts";
3030
import Migration0016 from "./Migrations/016_ProjectTargetColumns.ts";
31+
import Migration0017 from "./Migrations/017_ThreadNotes.ts";
3132
import { Effect } from "effect";
3233

3334
/**
@@ -57,6 +58,7 @@ const loader = Migrator.fromRecord({
5758
"14_RemoteTargetColumns": Migration0014,
5859
"15_ExecutionTargets": Migration0015,
5960
"16_ProjectTargetColumns": Migration0016,
61+
"17_ThreadNotes": Migration0017,
6062
});
6163

6264
/**
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import * as Effect from "effect/Effect";
2+
import * as SqlClient from "effect/unstable/sql/SqlClient";
3+
4+
export default Effect.gen(function* () {
5+
const sql = yield* SqlClient.SqlClient;
6+
7+
yield* sql`
8+
CREATE TABLE IF NOT EXISTS thread_notes (
9+
thread_id TEXT PRIMARY KEY,
10+
notes TEXT NOT NULL,
11+
created_at TEXT NOT NULL,
12+
updated_at TEXT NOT NULL
13+
)
14+
`;
15+
16+
yield* sql`
17+
CREATE INDEX IF NOT EXISTS idx_thread_notes_updated_at
18+
ON thread_notes(updated_at)
19+
`;
20+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import {
2+
type ThreadNotesDocument,
3+
ThreadNotesGetInput,
4+
ThreadNotesUpsertInput,
5+
} from "@t3tools/contracts";
6+
import { Option, ServiceMap } from "effect";
7+
import type { Effect } from "effect";
8+
9+
import type { ProjectionRepositoryError } from "../Errors.ts";
10+
11+
export interface ThreadNotesRepositoryShape {
12+
readonly getByThreadId: (
13+
input: typeof ThreadNotesGetInput.Type,
14+
) => Effect.Effect<Option.Option<ThreadNotesDocument>, ProjectionRepositoryError>;
15+
readonly upsert: (
16+
input: typeof ThreadNotesUpsertInput.Type,
17+
) => Effect.Effect<ThreadNotesDocument, ProjectionRepositoryError>;
18+
}
19+
20+
export class ThreadNotesRepository extends ServiceMap.Service<
21+
ThreadNotesRepository,
22+
ThreadNotesRepositoryShape
23+
>()("t3/persistence/Services/ThreadNotes/ThreadNotesRepository") {}

apps/server/src/serverLayers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/
1111
import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
1212
import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
1313
import { ExecutionTargetRepositoryLive } from "./persistence/Layers/ExecutionTargets";
14+
import { ThreadNotesRepositoryLive } from "./persistence/Layers/ThreadNotes";
1415
import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine";
1516
import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor";
1617
import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor";
@@ -151,5 +152,6 @@ export function makeServerRuntimeServicesLayer() {
151152
KeybindingsLive,
152153
executionTargetServiceLayer,
153154
executionTargetRuntimeLayer,
155+
ThreadNotesRepositoryLive,
154156
).pipe(Layer.provideMerge(NodeServices.layer));
155157
}

apps/server/src/wsServer.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1715,6 +1715,7 @@ describe("WebSocket Server", () => {
17151715
branches: [],
17161716
isRepo: false,
17171717
hasOriginRemote: false,
1718+
originWebUrl: null,
17181719
}),
17191720
);
17201721
const initRepo = vi.fn(() => Effect.void);
@@ -1745,7 +1746,12 @@ describe("WebSocket Server", () => {
17451746

17461747
const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: "/repo/path" });
17471748
expect(listResponse.error).toBeUndefined();
1748-
expect(listResponse.result).toEqual({ branches: [], isRepo: false, hasOriginRemote: false });
1749+
expect(listResponse.result).toEqual({
1750+
branches: [],
1751+
isRepo: false,
1752+
hasOriginRemote: false,
1753+
originWebUrl: null,
1754+
});
17491755
expect(listBranches).toHaveBeenCalledWith({ cwd: "/repo/path" });
17501756

17511757
const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: "/repo/path" });

0 commit comments

Comments
 (0)