From 942c95b2a9720654ccf02e84bde9110606539cf8 Mon Sep 17 00:00:00 2001 From: owen <185341197+oxwen11@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:33:08 -0400 Subject: [PATCH 1/3] feat(chat): show the workspace branch under the composer Sessions in a git work tree had no branch cue next to the prompt; probe the current HEAD and render it in the composer footer. Co-authored-by: Cursor --- apps/app/src/features/chat/chat.tsx | 12 ++++++- .../chat/components/chat-input-composer.tsx | 23 ++++++++++++-- packages/contract/package.json | 1 + packages/contract/src/git.ts | 16 ++++++++++ packages/contract/src/index.ts | 4 ++- packages/server/src/rpc/context.ts | 2 ++ packages/server/src/rpc/git.ts | 21 +++++++++++++ packages/server/src/rpc/router.ts | 2 ++ packages/server/src/rpc/runtime.ts | 2 ++ packages/server/test/rpc-git.test.ts | 31 +++++++++++++++++++ packages/server/test/rpc-harness.ts | 2 ++ packages/server/test/rpc-session.test.ts | 2 ++ 12 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 packages/contract/src/git.ts create mode 100644 packages/server/src/rpc/git.ts create mode 100644 packages/server/test/rpc-git.test.ts diff --git a/apps/app/src/features/chat/chat.tsx b/apps/app/src/features/chat/chat.tsx index f06a0fc41..b42e0357c 100644 --- a/apps/app/src/features/chat/chat.tsx +++ b/apps/app/src/features/chat/chat.tsx @@ -1,3 +1,5 @@ +import { useQuery } from "@tanstack/react-query"; +import { useRouteContext } from "@tanstack/react-router"; import type { SessionRef } from "@vibest/contract"; import { cn } from "@vibest/ui/lib/utils"; @@ -26,12 +28,20 @@ export function Chat({ */ cwd: string | undefined; }) { + const { orpcQueryUtils } = useRouteContext({ from: "__root__" }); + const branch = useQuery({ + ...orpcQueryUtils.git.branch.queryOptions({ input: { cwd: cwd ?? "" } }), + enabled: cwd !== undefined, + }); + const branchName = branch.data?.current ?? undefined; + return (
-
+
{/* Same slot the draft surface puts the harness picker in, so diff --git a/apps/app/src/features/chat/components/chat-input-composer.tsx b/apps/app/src/features/chat/components/chat-input-composer.tsx index 8752ffcb8..05def2f4e 100644 --- a/apps/app/src/features/chat/components/chat-input-composer.tsx +++ b/apps/app/src/features/chat/components/chat-input-composer.tsx @@ -6,7 +6,8 @@ import { PromptInputTools, } from "@vibest/ui/ai-elements/prompt-input"; import { Button } from "@vibest/ui/components/button"; -import { Card, CardFrame, CardFrameHeader } from "@vibest/ui/components/card"; +import { Card, CardFrame, CardFrameFooter, CardFrameHeader } from "@vibest/ui/components/card"; +import { GitBranchIcon } from "lucide-react"; import { type ReactNode, useState } from "react"; import { useStore } from "zustand"; @@ -130,7 +131,14 @@ function QueuedPromptList({ ); } -export function ChatInputComposer({ toolbar }: { toolbar?: ReactNode }) { +export function ChatInputComposer({ + toolbar, + branchName, +}: { + toolbar?: ReactNode; + /** Current git branch of the session workspace, when the probe landed. */ + branchName?: string; +}) { const { acknowledgeRecovery, prompt, steer, turnInProgress, store } = useChatSession(); const status = useStore(store, (state) => state.session.status); const activeTurnId = useStore(store, (state) => state.session.activeTurnId); @@ -219,6 +227,17 @@ export function ChatInputComposer({ toolbar }: { toolbar?: ReactNode }) { + {branchName ? ( + + + + + ) : null} ); } diff --git a/packages/contract/package.json b/packages/contract/package.json index f9fa7cc79..a4beac7cd 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -7,6 +7,7 @@ "exports": { ".": "./src/index.ts", "./fs": "./src/fs.ts", + "./git": "./src/git.ts", "./harness": "./src/harness.ts", "./pty": "./src/pty.ts", "./project": "./src/project.ts", diff --git a/packages/contract/src/git.ts b/packages/contract/src/git.ts new file mode 100644 index 000000000..da5436d1e --- /dev/null +++ b/packages/contract/src/git.ts @@ -0,0 +1,16 @@ +import { oc } from "@orpc/contract"; +import { Schema } from "effect"; + +import { toStandardSchema } from "./domain"; + +const CwdInput = Schema.Struct({ cwd: Schema.String }); + +/** Current HEAD name when the workspace is a git work tree; otherwise null. */ +export const GitBranchSchema = Schema.Struct({ + current: Schema.Union([Schema.String, Schema.Null]), +}); +export type GitBranch = typeof GitBranchSchema.Type; + +export const gitContract = { + branch: oc.input(toStandardSchema(CwdInput)).output(toStandardSchema(GitBranchSchema)), +}; diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index fb8435d55..162d036a1 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -1,4 +1,5 @@ import { fsContract } from "./fs"; +import { gitContract } from "./git"; import { harnessContract } from "./harness"; import { projectContract } from "./project"; import { ptyContract } from "./pty"; @@ -13,8 +14,9 @@ export const contract = { session: sessionContract, project: projectContract, fs: fsContract, + git: gitContract, pty: ptyContract, }; export type Contract = typeof contract; -export { fsContract, harnessContract, projectContract, ptyContract, sessionContract }; +export { fsContract, gitContract, harnessContract, projectContract, ptyContract, sessionContract }; diff --git a/packages/server/src/rpc/context.ts b/packages/server/src/rpc/context.ts index 5de860a98..97b057f49 100644 --- a/packages/server/src/rpc/context.ts +++ b/packages/server/src/rpc/context.ts @@ -3,6 +3,7 @@ import type { FileSystem } from "effect/FileSystem"; import type { EventBus } from "../events"; import type { FileSystemService } from "../fs"; +import type { GitService } from "../git"; import type { HarnessAgentRegistry, HarnessAgentSessionService, @@ -23,4 +24,5 @@ export type RpcContext = WithEffectContext< | ProjectService | PtyService | FileSystemService + | GitService >; diff --git a/packages/server/src/rpc/git.ts b/packages/server/src/rpc/git.ts new file mode 100644 index 000000000..23a22fe1b --- /dev/null +++ b/packages/server/src/rpc/git.ts @@ -0,0 +1,21 @@ +import "@orpc/experimental-effect/extensions/effect"; +import { implement } from "@orpc/server"; +import { gitContract } from "@vibest/contract/git"; +import { Effect } from "effect"; + +import { GitService } from "../git"; +import type { RpcContext } from "./context"; + +const orpc = implement(gitContract).$context(); + +export const gitRouter = orpc.router({ + branch: orpc.branch.effect(function* ({ input }) { + const git = yield* GitService; + return yield* git.branch(input.cwd).pipe( + Effect.map((summary) => ({ current: summary.current ?? null })), + Effect.catchTag("GitError", () => Effect.succeed({ current: null })), + ); + }), +}); + +export type GitRouter = typeof gitRouter; diff --git a/packages/server/src/rpc/router.ts b/packages/server/src/rpc/router.ts index 5baa22eb4..639e77e2f 100644 --- a/packages/server/src/rpc/router.ts +++ b/packages/server/src/rpc/router.ts @@ -2,6 +2,7 @@ import { os } from "@orpc/server"; import type { RpcContext } from "./context"; import { fsRouter } from "./fs"; +import { gitRouter } from "./git"; import { harnessRouter } from "./harness"; import { projectRouter } from "./project"; import { ptyRouter } from "./pty"; @@ -14,6 +15,7 @@ export const router = orpc.router({ session: sessionRouter, project: projectRouter, fs: fsRouter, + git: gitRouter, pty: ptyRouter, }); export type Router = typeof router; diff --git a/packages/server/src/rpc/runtime.ts b/packages/server/src/rpc/runtime.ts index ec7057a6e..8c9bc2575 100644 --- a/packages/server/src/rpc/runtime.ts +++ b/packages/server/src/rpc/runtime.ts @@ -8,6 +8,7 @@ import { Context, Effect, type FileSystem, Layer } from "effect"; import { PathsLayer } from "../config/paths"; import { EventBusLayer } from "../events"; import { FileSystemServiceLayer } from "../fs"; +import { GitServiceLayer } from "../git"; import { type HarnessAgentAdapter, HarnessAgentRegistry, @@ -172,6 +173,7 @@ export const AgentRuntimeLayer = Layer.mergeAll( HarnessListProvided, HarnessProbeProvided, FileSystemServiceLayer.pipe(Layer.provide(PlatformLayer)), + GitServiceLayer, PtyServiceProvided, PlatformLayer, // For the HTTP request app: `HttpStaticServer` needs it to turn a file into a diff --git a/packages/server/test/rpc-git.test.ts b/packages/server/test/rpc-git.test.ts new file mode 100644 index 000000000..6fb74514f --- /dev/null +++ b/packages/server/test/rpc-git.test.ts @@ -0,0 +1,31 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { simpleGit } from "simple-git"; +import { describe, expect, it } from "vitest"; + +import { makeRpcTestHarness } from "./rpc-harness"; + +describe("git router", () => { + it("returns the current branch for a work tree and null otherwise", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "vibest-home-")); + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "vibest-git-")); + const bare = fs.mkdtempSync(path.join(os.tmpdir(), "vibest-nogit-")); + fs.writeFileSync(path.join(repo, "a.txt"), "hi"); + const git = simpleGit(repo); + await git.raw(["init", "-b", "main"]); + await git.addConfig("user.email", "test@example.com"); + await git.addConfig("user.name", "Test"); + await git.add("."); + await git.commit("init"); + + const harness = await makeRpcTestHarness(home); + try { + await expect(harness.client.git.branch({ cwd: repo })).resolves.toEqual({ current: "main" }); + await expect(harness.client.git.branch({ cwd: bare })).resolves.toEqual({ current: null }); + } finally { + await harness.dispose(); + } + }); +}); diff --git a/packages/server/test/rpc-harness.ts b/packages/server/test/rpc-harness.ts index fb3ae658c..59988f00d 100644 --- a/packages/server/test/rpc-harness.ts +++ b/packages/server/test/rpc-harness.ts @@ -4,6 +4,7 @@ import { Layer, ManagedRuntime } from "effect"; import { layerPaths } from "../src/config/paths"; import { EventBusLayer } from "../src/events"; import { FileSystemServiceLayer } from "../src/fs"; +import { GitServiceLayer } from "../src/git"; import { HarnessAgentRegistry, HarnessAgentSessionManagerLayer, @@ -74,6 +75,7 @@ export async function makeRpcTestHarness( listLayer, probeLayer, FileSystemServiceLayer.pipe(Layer.provide(NodePlatformLayer)), + GitServiceLayer, ptyLayer, NodePlatformLayer, ), diff --git a/packages/server/test/rpc-session.test.ts b/packages/server/test/rpc-session.test.ts index 42efd7e34..942f21ff9 100644 --- a/packages/server/test/rpc-session.test.ts +++ b/packages/server/test/rpc-session.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vitest"; import { layerPaths } from "../src/config/paths"; import { EventBusLayer } from "../src/events"; import { FileSystemServiceLayer } from "../src/fs"; +import { GitServiceLayer } from "../src/git"; import { HarnessAgentRegistry, HarnessAgentSessionManagerLayer, @@ -120,6 +121,7 @@ async function setup() { HarnessListLayer.pipe(Layer.provide(registryLayer), Layer.provide(NodeServices.layer)), HarnessProbeLayer.pipe(Layer.provide(registryLayer)), FileSystemServiceLayer.pipe(Layer.provide(NodeServices.layer)), + GitServiceLayer, ptyServiceLayer, NodeServices.layer, Observability.discard, From 31126a9886bd77eb66ec33159efb1d66da62933a Mon Sep 17 00:00:00 2001 From: owen <185341197+oxwen11@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:34:02 -0400 Subject: [PATCH 2/3] fix(chat): keep the composer branch footer from jumping The footer appeared only after the git probe landed, so the composer shifted; reserve the slot and prefetch the branch with the session. Co-authored-by: Cursor --- apps/app/src/features/chat/chat.tsx | 1 + .../chat/components/chat-input-composer.tsx | 29 ++++++++++++------- apps/app/src/routes/session/$sessionId.tsx | 13 +++++++++ 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/apps/app/src/features/chat/chat.tsx b/apps/app/src/features/chat/chat.tsx index b42e0357c..a06b94150 100644 --- a/apps/app/src/features/chat/chat.tsx +++ b/apps/app/src/features/chat/chat.tsx @@ -42,6 +42,7 @@ export function Chat({
{/* Same slot the draft surface puts the harness picker in, so diff --git a/apps/app/src/features/chat/components/chat-input-composer.tsx b/apps/app/src/features/chat/components/chat-input-composer.tsx index 05def2f4e..06ff45886 100644 --- a/apps/app/src/features/chat/components/chat-input-composer.tsx +++ b/apps/app/src/features/chat/components/chat-input-composer.tsx @@ -134,10 +134,13 @@ function QueuedPromptList({ export function ChatInputComposer({ toolbar, branchName, + branchPending, }: { toolbar?: ReactNode; /** Current git branch of the session workspace, when the probe landed. */ branchName?: string; + /** True while the branch probe is in flight — reserve footer height. */ + branchPending?: boolean; }) { const { acknowledgeRecovery, prompt, steer, turnInProgress, store } = useChatSession(); const status = useStore(store, (state) => state.session.status); @@ -227,17 +230,21 @@ export function ChatInputComposer({ - {branchName ? ( - - - - - ) : null} + + + {branchPending ? ( + + ); } diff --git a/apps/app/src/routes/session/$sessionId.tsx b/apps/app/src/routes/session/$sessionId.tsx index 1040ffad6..33cbefe62 100644 --- a/apps/app/src/routes/session/$sessionId.tsx +++ b/apps/app/src/routes/session/$sessionId.tsx @@ -34,6 +34,17 @@ export const Route = createFileRoute("/session/$sessionId")({ loaderDeps: ({ search }) => search, loader: async ({ context, params, deps }) => { const { session } = context.orpcQueryUtils; + const prefetchBranch = async (projectId: string) => { + const projects = await context.queryClient.ensureQueryData( + context.orpcQueryUtils.project.list.queryOptions(), + ); + const cwd = projects.find((project) => project.id === projectId)?.path; + if (cwd) { + void context.queryClient.prefetchQuery( + context.orpcQueryUtils.git.branch.queryOptions({ input: { cwd } }), + ); + } + }; // Fast path: the link handed us a whole ref, so prepare can go first. We // return what the server answers with, not what the URL claimed. @@ -43,6 +54,7 @@ export const Route = createFileRoute("/session/$sessionId")({ harnessAgentId: deps.harness, sessionId: params.sessionId, }; + void prefetchBranch(hinted.projectId); const prepared = await session.prepare.call({ ref: hinted }).catch((error: unknown) => { // A bad hint and a session the harness has forgotten both fall through // to the lookup; only this line tells them apart afterwards. @@ -69,6 +81,7 @@ export const Route = createFileRoute("/session/$sessionId")({ // native history was cleaned up fails here — surface it instead of leaving // a silently dead chat. The page still renders, so keep the console trail // for diagnosis after the toast auto-dismisses. + void prefetchBranch(ref.projectId); await session.prepare.call({ ref }).catch((error: unknown) => { console.error("Failed to prepare session", error); toast.error( From f67c77d12859745e622fc2fa0e4ab8f1d28f225e Mon Sep 17 00:00:00 2001 From: owen <185341197+oxwen11@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:50:03 -0400 Subject: [PATCH 3/3] Query the session branch from the composer, like pie #52. The footer still hides when git.branch has no current name; Chat only forwards the route-resolved cwd because vibest's RPC keys on path, not sessionRef. Co-authored-by: Cursor --- apps/app/src/features/chat/chat.tsx | 11 +---------- .../chat/components/chat-input-composer.tsx | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/apps/app/src/features/chat/chat.tsx b/apps/app/src/features/chat/chat.tsx index b42e0357c..39797ba2d 100644 --- a/apps/app/src/features/chat/chat.tsx +++ b/apps/app/src/features/chat/chat.tsx @@ -1,5 +1,3 @@ -import { useQuery } from "@tanstack/react-query"; -import { useRouteContext } from "@tanstack/react-router"; import type { SessionRef } from "@vibest/contract"; import { cn } from "@vibest/ui/lib/utils"; @@ -28,20 +26,13 @@ export function Chat({ */ cwd: string | undefined; }) { - const { orpcQueryUtils } = useRouteContext({ from: "__root__" }); - const branch = useQuery({ - ...orpcQueryUtils.git.branch.queryOptions({ input: { cwd: cwd ?? "" } }), - enabled: cwd !== undefined, - }); - const branchName = branch.data?.current ?? undefined; - return (
{/* Same slot the draft surface puts the harness picker in, so diff --git a/apps/app/src/features/chat/components/chat-input-composer.tsx b/apps/app/src/features/chat/components/chat-input-composer.tsx index 05def2f4e..ebb07218c 100644 --- a/apps/app/src/features/chat/components/chat-input-composer.tsx +++ b/apps/app/src/features/chat/components/chat-input-composer.tsx @@ -1,3 +1,5 @@ +import { useQuery } from "@tanstack/react-query"; +import { useRouteContext } from "@tanstack/react-router"; import type { SessionRecoverySnapshot } from "@vibest/contract"; import { PromptInput, @@ -132,13 +134,18 @@ function QueuedPromptList({ } export function ChatInputComposer({ + cwd, toolbar, - branchName, }: { + /** Session workspace path, from the route. Used to probe the current branch. */ + cwd: string | undefined; toolbar?: ReactNode; - /** Current git branch of the session workspace, when the probe landed. */ - branchName?: string; }) { + const { orpcQueryUtils } = useRouteContext({ from: "__root__" }); + const branch = useQuery({ + ...orpcQueryUtils.git.branch.queryOptions({ input: { cwd: cwd ?? "" } }), + enabled: cwd !== undefined, + }); const { acknowledgeRecovery, prompt, steer, turnInProgress, store } = useChatSession(); const status = useStore(store, (state) => state.session.status); const activeTurnId = useStore(store, (state) => state.session.activeTurnId); @@ -227,14 +234,14 @@ export function ChatInputComposer({ - {branchName ? ( + {branch.data?.current ? ( ) : null}