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
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ body:
id: version
attributes:
label: T4 Code version
placeholder: "0.1.0"
placeholder: "0.1.1"
validations:
required: true
- type: dropdown
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Install browser for end-to-end tests
run: pnpm exec playwright install --with-deps chromium

- name: Check source and types
run: pnpm check

Expand All @@ -43,5 +46,8 @@ jobs:
- name: Build all workspaces
run: pnpm build

- name: Run built-app end-to-end tests
run: pnpm test:e2e

- name: Check packaging contract
run: pnpm test:packaging
43 changes: 43 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,49 @@ env:
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}

jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Check out release tag
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ env.RELEASE_TAG }}

- name: Install pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
version: 11.10.0

- name: Install Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24.13.1
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Install browser for end-to-end tests
run: pnpm exec playwright install --with-deps chromium

- name: Check source and types
run: pnpm check

- name: Run workspace tests
run: pnpm test

- name: Run built-app end-to-end tests
run: pnpm test:e2e

- name: Build all workspaces
run: pnpm build

- name: Check packaging contract
run: pnpm test:packaging

build-linux:
needs: verify
runs-on: ubuntu-24.04
timeout-minutes: 35
steps:
Expand Down Expand Up @@ -64,6 +106,7 @@ jobs:
retention-days: 7

build-macos:
needs: verify
runs-on: macos-15
timeout-minutes: 40
steps:
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@t4-code/desktop",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"main": "dist-electron/main.cjs",
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/target-manager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createOmpClient, OmpClientError, type CommandIntent, type CursorStore, type OmpClient, type PublicServerFrame } from "@t4-code/client";
import { createOmpClient, isConfirmationDecisionConsumed, OmpClientError, type CommandIntent, type CursorStore, type OmpClient, type PublicServerFrame } from "@t4-code/client";
import type { CommandResult, ConnectionStateEvent, RuntimeErrorEvent } from "@t4-code/protocol/desktop-ipc";
import type { ConfirmRequest, ConfirmResult, TerminalCloseRequest, TerminalInputRequest, TerminalResizeRequest, TerminalResult } from "@t4-code/protocol/desktop-ipc";
import { ADDITIVE_FEATURES, DEVICE_CAPABILITIES, type DeviceCapability } from "@t4-code/protocol";
Expand Down Expand Up @@ -210,7 +210,7 @@ export class DesktopTargetManager {
requestId: String(result.requestId),
confirmationId: request.confirmationId,
commandId: request.commandId,
accepted: result.ok,
accepted: isConfirmationDecisionConsumed(result),
};
} catch (error) {
if (this.generations.get(targetId) !== generation && error instanceof OmpClientError && error.code === "closed")
Expand Down Expand Up @@ -324,7 +324,7 @@ export class DesktopTargetManager {
cursorStore: this.cursorStoreFactory(targetId),
capabilities: requestedCapabilities,
requestedFeatures: ADDITIVE_FEATURES,
client: { name: "T4 Code", version: "0.1.0", build: "desktop", platform: process.platform },
client: { name: "T4 Code", version: "0.1.1", build: "desktop", platform: process.platform },
reconnect: { attemptCap: 12, baseMs: 250, maxMs: 10_000 },
};
const client = createOmpClient(clientOptions);
Expand Down
62 changes: 61 additions & 1 deletion apps/desktop/test/target-manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { DESKTOP_IPC_CHANNELS, decodeDesktopInvokeRequest } from "@t4-code/protocol/desktop-ipc";
import type { CursorStore, OmpTransport } from "@t4-code/client";
import { DEVICE_CAPABILITIES, hostId, type WelcomeFrame } from "@t4-code/protocol";
import { commandId, confirmationId, DEVICE_CAPABILITIES, hostId, sessionId, type WelcomeFrame } from "@t4-code/protocol";
import { validEvent } from "../src/ipc.ts";
import { DesktopTargetManager } from "../src/target-manager.ts";
import type { RemoteTargetRecord, RemoteTargetRegistry } from "../src/remote-runtime/registry.ts";
Expand Down Expand Up @@ -230,6 +230,66 @@ describe("desktop target manager boundaries", () => {
expect((await rejected).accepted).toBe(false);
await runtime.close();
});
it("treats a host-acknowledged denial as a consumed confirmation decision", async () => {
const transports: Transport[] = [];
const runtime = new DesktopTargetManager({
cursorStore: new Store(),
transportFactory: () => {
const next = new Transport();
transports.push(next);
return next as never;
},
events: { onFrame: () => {}, onState: () => {}, onError: () => {} },
});
await runtime.connect();
const transport = transports[0];
if (transport === undefined) throw new Error("transport was not created");

const heldCommand = runtime.command({
hostId: "host-fixture",
sessionId: "session-a",
command: "session.cancel",
args: {},
});
const command = await transport.waitForSent(1);
transport.receive({
v: V,
type: "confirmation",
confirmationId: "confirm-deny",
commandId: command.commandId,
hostId: "host-fixture",
sessionId: "session-a",
commandHash: "sha256:fixture",
revision: "revision-a",
expiresAt: "2999-01-01T00:00:00.000Z",
summary: "session.cancel",
});
const confirmed = runtime.confirm({
targetId: "local",
confirmationId: confirmationId("confirm-deny"),
commandId: commandId(String(command.commandId)),
hostId: hostId("host-fixture"),
sessionId: sessionId("session-a"),
decision: "deny",
});
await transport.waitForSent(2);
transport.receive({
v: V,
type: "response",
requestId: command.requestId,
commandId: command.commandId,
hostId: "host-fixture",
sessionId: "session-a",
command: "session.cancel",
ok: false,
error: { code: "confirmation_denied", message: "command was denied" },
});
const [commandResult, confirmationResult] = await Promise.all([heldCommand, confirmed]);
expect(commandResult.accepted).toBe(false);
expect(confirmationResult.accepted).toBe(true);
expect(confirmationResult.requestId).toBe(command.requestId);
await runtime.close();
});
it("reads remote auth on each Hello without retaining credential material", async () => {
const transports: Transport[] = [];
const registry = new Registry();
Expand Down
2 changes: 1 addition & 1 deletion apps/site/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@t4-code/site",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"scripts": {
Expand Down
5 changes: 4 additions & 1 deletion apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"
/>
<meta name="color-scheme" content="light dark" />
<title>T4 Code</title>
<script src="./t4-bootstrap.js"></script>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@t4-code/web",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"scripts": {
Expand Down
34 changes: 31 additions & 3 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
html,
body {
height: 100%;
height: 100dvh;
max-width: 100%;
margin: 0;
padding: 0;
overscroll-behavior: none;
Expand All @@ -31,6 +33,8 @@

#root {
height: 100%;
height: 100dvh;
max-width: 100%;
width: 100%;
overflow: hidden;
}
Expand Down Expand Up @@ -58,14 +62,38 @@

.workspace-topbar {
display: flex;
height: var(--workspace-topbar-height);
min-height: var(--workspace-topbar-height);
height: calc(var(--workspace-topbar-height) + env(safe-area-inset-top));
min-height: calc(var(--workspace-topbar-height) + env(safe-area-inset-top));
padding-top: env(safe-area-inset-top);
flex-shrink: 0;
align-items: center;
}

.surface-subheader {
@apply flex h-10 min-h-10 shrink-0 items-center border-border/60 border-b bg-background;
@apply flex h-13 min-h-13 shrink-0 items-center border-border/60 border-b bg-background;
}

@media (min-width: 40rem) {
.surface-subheader {
@apply h-10 min-h-10;
}
}

/* Right-pane and rail sheets can contain dense desktop-native controls.
On phones their visible chassis stays compact, but the actual interactive
box must remain thumb-safe rather than relying on a pseudo hit target. */
@media (max-width: 39.999rem) {
[data-slot="sheet-popup"]
:where(
button,
[role="button"],
input:not([type="checkbox"]):not([type="radio"]),
select,
textarea
) {
min-width: 2.75rem;
min-height: 2.75rem;
}
}

/* macOS traffic lights live at the top-left of the frameless window.
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { CommandPalette } from "./CommandPalette.tsx";
import { CollapsedRail, Rail } from "./Rail.tsx";
import { ResizeHandle } from "./ResizeHandle.tsx";
import { Titlebar } from "./Titlebar.tsx";
import { resolveRailTogglePresentation } from "./rail-toggle.ts";

export function AppShell() {
const navigate = useNavigate();
Expand Down Expand Up @@ -92,15 +93,21 @@ export function AppShell() {
// Rail collapse/expand animates width via .rail-dock; the center column
// reflows with it and keeps its own focus and scroll.
const effectiveRailWidth = railPreviewWidth ?? railWidth;
const railToggle = resolveRailTogglePresentation({
overlaid: railOverlaid,
overlayOpen: railOverlayOpen,
collapsed: railCollapsed,
});

return (
<div className="flex h-full flex-col bg-background text-foreground">
<div className="flex h-full min-h-0 min-w-0 max-w-full flex-col overflow-x-hidden bg-background text-foreground">
<Titlebar
onToggleRail={() => {
const state = workspaceStore.getState();
if (railOverlaid) state.setRailOverlayOpen(!state.railOverlayOpen);
else state.setRailCollapsed(!state.railCollapsed);
}}
railToggle={railToggle}
/>
<div className="flex min-h-0 flex-1">
{!railOverlaid && (
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/components/HomePane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ function DesktopHomePane({
const targetsApi = useMemo(() => createTargetsStore(controller, {}), [controller]);
const actionsState = useSyncExternalStore(actions.subscribe, actions.getState);
const state = snapshot === null ? null : deriveDesktopHomeState(snapshot);
const browserDirect = shell.serviceInspect === undefined;
const needsInspection = state !== null && state.kind === "service";

// Entering the service state reads the real service once; every action
Expand Down Expand Up @@ -118,10 +119,11 @@ function DesktopHomePane({
<EmptyMedia variant="default">
<BrandLockup byline size="lg" />
</EmptyMedia>
<EmptyTitle>No sessions yet</EmptyTitle>
<EmptyTitle>{browserDirect ? "Choose a live session" : "No sessions yet"}</EmptyTitle>
<EmptyDescription>
This machine is connected. Sessions you start here or from a paired device appear in
the list on the left the moment they exist.
{browserDirect
? "This Tailnet connection is live. Choose a session from the list on the left to inspect it."
: "This machine is connected. Sessions you start here or from a paired device appear in the list on the left the moment they exist."}
</EmptyDescription>
</EmptyHeader>
</Empty>
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/Rail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ function ProjectHeaderRow({ group }: { group: ProjectGroup }) {
<div className="flex items-center gap-0.5">
<button
aria-expanded={group.expanded}
className="flex min-w-0 flex-1 items-center gap-1 rounded-md px-1.5 py-1 text-left outline-none transition-colors duration-(--motion-duration-fast) hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
className="flex min-h-11 min-w-0 flex-1 items-center gap-1 rounded-md px-1.5 py-1 text-left outline-none transition-colors duration-(--motion-duration-fast) hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background sm:min-h-0"
onClick={() =>
workspaceStore.getState().setProjectExpanded(group.project.id, !group.expanded)
}
Expand Down Expand Up @@ -171,7 +171,7 @@ function ProjectHeaderRow({ group }: { group: ProjectGroup }) {
render={
<IconButton
aria-label={`New session in ${group.project.name}`}
className="size-6 shrink-0"
className="size-11 shrink-0 sm:size-6"
disabled={pending}
onClick={handleCreate}
size="icon-xs"
Expand Down
Loading
Loading