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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ that section and uses it as the GitHub release description, which the app displa
as-is. The install instructions block (after the `<!-- gh-only -->` marker) is added
by `release.yml` and stays **only** on the GitHub page — it does not appear in the app.

## v2.3.1

- Fix: **artifacts published by Claude open again** — inline artifact cards no longer show "Unavailable", and artifact links in Claude's replies are back to clickable cards (Claude Code 2.1.272 changed the artifact link format).
- Fix: with Caffeinate in **Light** mode, the Mac no longer falls asleep in the short gap between a background task finishing and Claude picking the work back up — it now waits 60 s before letting the Mac sleep.
- Change: **Settings have fewer tabs** — **General** (Accounts · System), a new **Display** tab (appearance, thread, durations, composer, models, ordering) and **Claude Code** (instructions, behavior, helpers). Nothing changes in what the settings do; use the settings search to find one that moved.

## v2.3.0

- New: **messages between conversations are readable** — the receiving conversation shows which conversation sent a message, and the sender shows a compact "Message to …" line with its delivery status.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tosse-code",
"version": "2.3.0",
"version": "2.3.1",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "tosse-code"
version = "2.3.0"
version = "2.3.1"
description = "Flight Deck — desktop app to pilot Claude Code"
authors = ["Tosse"]
edition = "2021"
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Flight Deck",
"version": "2.3.0",
"version": "2.3.1",
"identifier": "com.tosse.desktop",
"build": {
"beforeDevCommand": "pnpm dev",
Expand Down
2 changes: 1 addition & 1 deletion src/features/conversation/ArtifactRefCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// A compact, pretty clickable card for an artifact LINK that Claude writes in its prose
// (`[label](https://claude.ai/code/artifact/<uuid>)`) — rendered in place of a plain anchor by
// (`[label](https://claude.ai/artifact/<id>)`, or the pre-2.1.272 `…/code/artifact/<uuid>`) — rendered in place of a plain anchor by
// StreamMarkdown's link renderer. When the artifact belongs to this conversation it is enriched
// from the registry (favicon + title) and opens in the in-app viewer; otherwise it opens the
// hosted page in the browser. Inline-block so it flows inside a paragraph.
Expand Down
4 changes: 2 additions & 2 deletions src/features/conversation/AuthWarningBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function AuthWarningBar({ session }: { session: string }) {
<span className="cv-reviewbar-label">
{name} CLI not found — the next messages will fail.
</span>
<button className="cv-reviewbar-btn" onClick={() => openSettings("accounts")}>
<button className="cv-reviewbar-btn" onClick={() => openSettings("general", "accounts")}>
Settings
</button>
</div>
Expand All @@ -58,7 +58,7 @@ export function AuthWarningBar({ session }: { session: string }) {
<span className="cv-reviewbar-label">
{name} account not connected — the next messages will fail.
</span>
<button className="cv-reviewbar-btn" onClick={() => openSettings("accounts")}>
<button className="cv-reviewbar-btn" onClick={() => openSettings("general", "accounts")}>
Sign in
</button>
</div>
Expand Down
5 changes: 5 additions & 0 deletions src/features/conversation/artifactOpen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ describe("isArtifactUrl", () => {
expect(isArtifactUrl("https://claude.ai/code/artifact/acecfb35-f63b-49c3-b835-d0c856695a94")).toBe(true);
});

it("matches the 2.1.272+ shape (claude.ai/artifact/<id>) — else a prose link renders as a bare anchor", () => {
expect(isArtifactUrl("https://claude.ai/artifact/66XHYkMzjJ4BdfJ64qa3cy")).toBe(true);
});

it("rejects other claude.ai URLs and non-artifact links", () => {
expect(isArtifactUrl("https://claude.ai/code/artifacts")).toBe(false); // the gallery, not one artifact
expect(isArtifactUrl("https://claude.ai/artifacts")).toBe(false);
expect(isArtifactUrl("https://claude.ai/code/session_01ABC")).toBe(false);
expect(isArtifactUrl("https://example.com/x")).toBe(false);
expect(isArtifactUrl("/abs/path.html")).toBe(false);
Expand Down
19 changes: 19 additions & 0 deletions src/features/conversation/artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,17 @@ describe("artifactUrlFromResult", () => {
it("handles the array content shape ({text})", () => {
expect(artifactUrlFromResult([{ type: "text", text: SHORT("/tmp/x.html", URL_B) }] as unknown as JsonValue)).toBe(URL_B);
});
it("parses the 2.1.272+ URL shape (claude.ai/artifact/<base58 id>, no /code/)", () => {
// Verbatim first lines of a real 2.1.272 ack: new host path + "(Version N)" suffix + a
// "Stored — contract …" paragraph. Missing this shape turned every card "Unavailable".
const url = "https://claude.ai/artifact/66XHYkMzjJ4BdfJ64qa3cy";
const ack = `Published /private/tmp/claude-501/x/scratchpad/catalogue/index.html at ${url} (Version 1)\n\nStored — contract 0.2.52 · capabilities db · sharing owner.`;
expect(artifactUrlFromResult(ack)).toBe(url);
});
it("does not mistake the artifacts GALLERY for an artifact", () => {
expect(artifactUrlFromResult("See https://claude.ai/code/artifacts")).toBeNull();
expect(artifactUrlFromResult("See https://claude.ai/artifacts")).toBeNull();
});
it("returns null on empty / missing / non-canonical text (degrade, no dead link)", () => {
expect(artifactUrlFromResult(undefined)).toBeNull();
expect(artifactUrlFromResult("")).toBeNull();
Expand Down Expand Up @@ -133,6 +144,14 @@ describe("selectArtifacts", () => {
expect(selectArtifacts(e)).toEqual([]);
});

it("skips asset uploads (asset:true carries a file_path but is not a page of its own)", () => {
const e = entryOf(
[{ id: "t1", blocks: [tuse("a1", { url: URL_A, file_path: "/tmp/logo.png", asset: true })] }],
{ a1: "Uploaded" },
);
expect(selectArtifacts(e)).toEqual([]);
});

it("ignores non-Artifact tool_uses", () => {
const e = entryOf([{ id: "t1", blocks: [tuse("u1", { file_path: "/tmp/x.html" }, "Write")] }]);
expect(selectArtifacts(e)).toEqual([]);
Expand Down
40 changes: 28 additions & 12 deletions src/features/conversation/artifacts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Front-derived registry of the artifacts Claude published in a conversation via the
// `Artifact` tool (a hosted HTML/MD page at claude.ai/code/artifact/<uuid>).
// `Artifact` tool (a hosted HTML/MD page at claude.ai/artifact/<id>).
//
// Everything is DERIVED from the message stream already in `conversationStore` — the
// `Artifact` tool_use inputs (file_path / description / favicon / label) joined to their
Expand All @@ -18,10 +18,27 @@ import { resultText } from "../../agent/subagentMeta";
import { basename } from "./toolMeta";

/** The canonical hosted-artifact URL shape. The publish tool_result is free text that
* ALWAYS begins "Published <abs_path> at https://claude.ai/code/artifact/<uuid>"; we anchor
* on this shape rather than parsing the surrounding human prose (which drifts across CLI
* versions — short vs long "To update:" forms). */
export const ARTIFACT_URL_RE = /https:\/\/claude\.ai\/code\/artifact\/[A-Za-z0-9-]+/;
* ALWAYS begins "Published <abs_path> at <url>"; we anchor on the URL shape rather than
* parsing the surrounding human prose (which drifts across CLI versions — short vs long
* "To update:" forms, the "(Version N)" suffix).
*
* ⚠️ BOTH shapes, on purpose. Up to claude 2.1.270 the URL was
* `https://claude.ai/code/artifact/<uuid>`; from 2.1.272 it is `https://claude.ai/artifact/<id>`
* (a ~22-char base58 id). Transcripts written by older binaries keep the old URL forever, so
* dropping it would regress every conversation already on disk. Missing the new one is what
* turned every card "Unavailable" and every prose link into a bare anchor. The trailing `/`
* keeps the gallery (`…/code/artifacts`) out. */
export const ARTIFACT_URL_RE = /https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9-]+/;

/** True when an `Artifact` tool_use is a real PUBLISH of a local page — the only call that is a
* deliverable. The tool also does `action:"list"`/`"read"`/`"quickstart"` and bare `url`-updates
* (no `file_path`), and `asset:true` UPLOADS (a `file_path` that is an image/font/PDF pushed into
* an existing artifact's asset store — not a page of its own). SINGLE source of truth for the
* inline card (`groupBlocks`) and the chip's list (`selectArtifacts`), so they never disagree. */
export function isArtifactPublish(input: JsonValue): boolean {
if (!field(input, "file_path")) return false;
return (input as Record<string, JsonValue>).asset !== true;
}

/** Pull the published URL out of an `Artifact` tool_result. Null while the publish is still
* in flight (no result yet) or if the ack is ever reworded past the canonical URL shape —
Expand Down Expand Up @@ -52,7 +69,7 @@ export interface ArtifactVersion {

/** An artifact grouped across its versions for one conversation. */
export interface Artifact {
/** Hosted URL (claude.ai/code/artifact/<uuid>). Null only in the brief window between a
/** Hosted URL (see {@link ARTIFACT_URL_RE}). Null only in the brief window between a
* publish tool_use and its tool_result landing. */
url: string | null;
/** Emoji favicon — of the most recent version that set it (last-known-good, so a republish
Expand Down Expand Up @@ -98,9 +115,9 @@ function artifactTitle(latestLabel: string | null, latestFilePath: string): stri
* maps to the same URL, but the URL is only known once the tool_result lands — file_path is
* known at tool_use time, so it is the stable provisional key that never splits a republish
* into two items. Labels repeat across different files, so they are never a key.
* - Tool_uses with no file_path (an `action:"list"` or a bare cross-conversation url-update) are
* skipped — they don't describe a local publish. (The inline card path mirrors this guard in
* `groupBlocks`.)
* - Anything but a real publish ({@link isArtifactPublish}: list/read/quickstart, a bare
* cross-conversation url-update, an asset upload) is skipped — it doesn't describe a local page.
* (The inline card path uses the same predicate in `groupBlocks`.)
* - An artifact whose EVERY publish terminally FAILED (all versions `is_error`, no URL) is dropped
* from this list: it is not an openable artifact, so it must not inflate the "Artifacts (N)" chip
* nor sit there mislabelled as "not published yet". The failure is still surfaced in the thread
Expand All @@ -116,9 +133,8 @@ export function selectArtifacts(entry: SessionEntry | undefined): Artifact[] {
const turn = entry.turns[t.id];
if (!turn || turn.role !== "assistant" || turn.parentToolUseId !== null) continue;
for (const b of turn.blocks) {
if (b.type !== "tool_use" || b.name !== "Artifact") continue;
const filePath = field(b.input, "file_path");
if (!filePath) continue;
if (b.type !== "tool_use" || b.name !== "Artifact" || !isArtifactPublish(b.input)) continue;
const filePath = field(b.input, "file_path")!;
const label = field(b.input, "label") ?? null;
const description = field(b.input, "description") ?? null;
const favicon = field(b.input, "favicon") ?? null;
Expand Down
7 changes: 7 additions & 0 deletions src/features/conversation/toolGroup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,13 @@ describe("groupBlocks — Artifact (publish only)", () => {
const urlUpdate = groupBlocks([tool("u", "Artifact", { url: "https://claude.ai/code/artifact/abc" })]);
expect(urlUpdate.map((s) => s.kind)).toEqual(["run"]);
});

it("does NOT treat an asset upload (asset:true + file_path) as an artifact card", () => {
const upload = groupBlocks([
tool("u", "Artifact", { url: "https://claude.ai/artifact/abc", file_path: "/tmp/logo.png", asset: true }),
]);
expect(upload.map((s) => s.kind)).toEqual(["run"]);
});
});

describe("groupBlocks — in-band markers (mid-turn separator)", () => {
Expand Down
12 changes: 6 additions & 6 deletions src/features/conversation/toolGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { parseMcpToolName, prettyMcpServer } from "../../agent/toolNames";
import { basename, toolMeta } from "./toolMeta";
import { diffCounts, lineDiff } from "./lineDiff";
import { isAgentMessagingTool } from "./agentMessage";
import { isArtifactPublish } from "./artifacts";

/** Lucide-ish icon token per tool, resolved by the UI's <Ico>. Shared so the live
* step rows and the static transcript pick the same glyph for a given tool. */
Expand Down Expand Up @@ -200,12 +201,11 @@ export function groupBlocks(
// An Artifact PUBLISH is its own inline card (a deliverable link) — breaks the run so the
// published page stands out instead of hiding in a "Ran N steps" step row (and its .html
// file_path never renders as an editor-opening chip pointing at a disposable temp file).
// Only a real publish qualifies: the `Artifact` tool also does `action:"list"` (enumerate
// the user's artifacts) and bare cross-conversation `url`-updates, which carry NO file_path
// and are NOT a local deliverable — those fall through to the normal run/step path so their
// result is shown like any other tool. This MUST mirror `selectArtifacts` (the chip's
// derivation, which skips file_path-less calls) or the two surfaces disagree.
if (b.name === "Artifact" && field(b.input, "file_path")) {
// Only a real publish qualifies: the `Artifact` tool also lists/reads, does bare
// cross-conversation `url`-updates and uploads assets, none of which is a local deliverable
// — those fall through to the normal run/step path so their result is shown like any other
// tool. The predicate is shared with `selectArtifacts` so the two surfaces never disagree.
if (b.name === "Artifact" && isArtifactPublish(b.input)) {
run = null;
out.push({ kind: "artifact", key: `art-${i}`, step: { id: b.id, name: b.name, input: b.input } });
return;
Expand Down
59 changes: 8 additions & 51 deletions src/features/power/CaffeinateHost.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,14 @@
import { useEffect } from "react";
import { commands } from "../../ipc/client";
import { useFleetCounts } from "../../agent/fleet";
import { useRunningCountsByConv } from "../../store/backgroundTasksStore";
import { useAppErrors } from "../../store/appErrors";
import { caffeineDesired, useCaffeinate } from "../../store/caffeinate";

/** Slow heartbeat re-asserting the keep-awake hold: if the `caffeinate` child is killed out
* from under us while it should stay held, the next tick calls `set_awake(true)` again and the
* idempotent Rust `hold()` respawns it. Cheap (a no-op while the child is still alive). */
const REASSERT_MS = 30_000;

// Serialize every `set_awake` IPC so the calls apply in ISSUE ORDER and the last intent wins.
// The heartbeat above can have an in-flight `setAwake(true)` that — without this — could reach
// the Rust mutex AFTER a near-simultaneous release's `setAwake(false)` (Tauri does not guarantee
// cross-invoke ordering) and strand the Mac held awake, with no further heartbeat to self-correct
// while `desired` is false. Chaining makes a later-issued release always win. Same "serialize the
// writes to a shared resource" discipline as the CLI-config writers.
let awakeChain: Promise<unknown> = Promise.resolve();
function setAwakeSerialized(desired: boolean) {
const call = awakeChain.then(() => commands.setAwake(desired));
awakeChain = call.catch(() => {}); // keep the chain alive past a rejection
return call;
}
import { caffeineDesired, releaseGraceMs, useCaffeinate } from "../../store/caffeinate";
import { useAwakeAssertion } from "./useAwakeAssertion";

/**
* The Caffeinate POLICY, mounted once globally (render-null). Watches the on/off toggle,
* the Light/Hard mode and live fleet activity, computes whether the Mac should be held
* awake right now, and pushes that boolean to the Rust `power` service via `set_awake`.
* awake right now, and pushes that boolean to the Rust `power` service via
* {@link useAwakeAssertion} — which, in Light mode, holds a little past the moment the fleet
* goes idle ({@link releaseGraceMs}) to bridge the gap before a follow-up turn reads as busy.
*
* Activity source for Light mode = "is ANY agent working". `useFleetCounts().running` folds
* a running turn and the `backgrounding` state, but it does NOT count a conversation whose
Expand All @@ -36,10 +18,8 @@ function setAwakeSerialized(desired: boolean) {
* mode would let the Mac sleep and stall a background sub-agent — exactly what the feature
* exists to prevent.
*
* If holding the assertion fails (a `caffeinate` spawn failure), we surface it via the app
* error banner instead of letting the toggle read "on" while the Mac quietly sleeps — the
* "zero silent error" rule. Its own component (not folded into App) so this subscription
* re-renders in isolation on every fleet tick.
* Its own component (not folded into App) so this subscription re-renders in isolation on
* every fleet tick.
*/
export function CaffeinateHost() {
const enabled = useCaffeinate((s) => s.enabled);
Expand All @@ -48,30 +28,7 @@ export function CaffeinateHost() {
const anyAgentActive =
useFleetCounts().running > 0 || Object.values(anyBackgroundTask).some((n) => n > 0);

const desired = caffeineDesired(enabled, mode, anyAgentActive);

useEffect(() => {
const push = async () => {
const res = await setAwakeSerialized(desired);
// Only a hold (desired === true) can fail; a release never does. Surface it so the
// user knows the Mac may sleep despite the toggle showing "on". Deduped by message.
if (desired && res.status === "error") {
useAppErrors
.getState()
.pushError("Couldn't keep the Mac awake — it may go to sleep.", res.error);
}
};
void push();
// While the assertion is meant to be HELD, re-assert it on a slow heartbeat. This effect
// only re-runs when `desired` flips, so if the `caffeinate` child dies out from under us
// while `desired` stays true (killall, an OS reap under pressure) nothing else calls
// set_awake again — the Rust-side liveness prune + respawn in `hold()` only runs when
// invoked. A cheap idempotent re-assert (a no-op while the child is alive) closes that
// self-heal gap. Not needed while releasing (desired === false).
if (!desired) return;
const id = setInterval(() => void push(), REASSERT_MS);
return () => clearInterval(id);
}, [desired]);
useAwakeAssertion(caffeineDesired(enabled, mode, anyAgentActive), releaseGraceMs(enabled, mode));

return null;
}
Loading
Loading