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
7 changes: 7 additions & 0 deletions apps/ios/CovenCave/CovenCave/State/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ final class AppModel {
var familiarOrder: [String] = []

var threads: [ChatThread] = []
/// Default Chats destination: the newest active conversation. Pinning only
/// affects list order and never makes an older thread the launch default.
var mostRecentThread: ChatThread? {
threads
.filter { !$0.archived }
.max { $0.updatedAt < $1.updatedAt }
}
/// Process-lifetime launch intent. It survives destination remounts until a
/// matching hydrated thread can be opened, then is consumed exactly once.
var launchThreadId: String?
Expand Down
15 changes: 15 additions & 0 deletions apps/ios/CovenCave/CovenCave/Views/ChatsHomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,11 @@ struct ChatsHomeView: View {
.onAppear {
consumeLaunchThreadIntent()
consumeGlobalRequests()
selectMostRecentThreadIfNeeded()
}
.onChange(of: app.threads.map(\.id)) { _, _ in
consumeLaunchThreadIntent()
selectMostRecentThreadIfNeeded()
}
// A slash command (`/new`, `/familiar <name>`) or a task link asked to
// open a specific thread — surface it in the detail column.
Expand Down Expand Up @@ -390,6 +392,19 @@ struct ChatsHomeView: View {
open(.thread(thread))
}

/// Open Chats at the latest active conversation without stealing focus from
/// a deep link, cross-view handoff, New Chat, or an existing selection.
private func selectMostRecentThreadIfNeeded() {
guard selection == nil,
!showNewChat,
app.threadToOpen == nil,
app.launchThreadId == nil,
!app.newChatRequested,
let thread = app.mostRecentThread
else { return }
open(.thread(thread))
}

/// Consume a cross-destination thread handoff on first appearance and on
/// later updates. Clearing the one-shot intent prevents re-appearance from
/// reopening the same conversation.
Expand Down
2 changes: 1 addition & 1 deletion apps/ios/CovenCave/CovenCave/Views/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ struct ConnectingView: View {
.accessibilityHidden(true)
.padding(.bottom, 34)

Text("Opening the Cave")
Text("Entering the Cave")
.font(.title.weight(.medium))
.fontDesign(.serif)
.italic()
Expand Down
27 changes: 27 additions & 0 deletions apps/ios/CovenCave/CovenCaveTests/LaunchThreadIntentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,31 @@ final class LaunchThreadIntentTests: XCTestCase {
app.threads = [expected]
XCTAssertTrue(app.consumeLaunchThreadIntent() === expected)
}

func testMostRecentThreadUsesUpdateTimeAndSkipsArchivedThreads() {
let app = AppModel()
let olderPinned = ChatThread(id: "older-pinned", title: "Older pinned", familiarIds: [])
olderPinned.updatedAt = Date(timeIntervalSince1970: 100)
olderPinned.pinned = true

let newest = ChatThread(id: "newest", title: "Newest", familiarIds: [])
newest.updatedAt = Date(timeIntervalSince1970: 200)

let archived = ChatThread(id: "archived", title: "Archived", familiarIds: [])
archived.updatedAt = Date(timeIntervalSince1970: 300)
archived.archived = true

app.threads = [olderPinned, archived, newest]

XCTAssertTrue(app.mostRecentThread === newest)
}

func testMostRecentThreadIsNilWithoutAnActiveConversation() {
let app = AppModel()
let archived = ChatThread(id: "archived", title: "Archived", familiarIds: [])
archived.archived = true
app.threads = [archived]

XCTAssertNil(app.mostRecentThread)
}
}
21 changes: 18 additions & 3 deletions scripts/ios-claude-design-fidelity.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ assert.match(
/func consumeLaunchThreadIntent\(\) -> ChatThread\?/,
"the launch thread intent waits for a matching thread before consuming",
);
assert.match(
appModel,
/var mostRecentThread: ChatThread\? \{[\s\S]{0,180}filter \{ !\$0\.archived \}[\s\S]{0,180}max \{ \$0\.updatedAt < \$1\.updatedAt \}/,
"the default chat is the newest active thread, independent of pin order",
);
assert.match(
appModel,
/if let threadId = ChatNotifications\.threadId\(fromDeepLink: url\) \{[\s\S]{0,140}launchThreadId = threadId[\s\S]{0,140}selectedTab = \.chats/,
Expand All @@ -109,8 +114,18 @@ assert.doesNotMatch(
);
assert.match(
home,
/onChange\(of: app\.threads\.map\(\\\.id\)\)[\s\S]{0,120}consumeLaunchThreadIntent\(\)/,
"Chats retries a pending launch intent when hydration adds threads",
/\.onAppear \{\s*consumeLaunchThreadIntent\(\)\s*consumeGlobalRequests\(\)\s*selectMostRecentThreadIfNeeded\(\)\s*\}/,
"Chats selects the most recent thread after honoring explicit launch requests",
);
assert.match(
home,
/onChange\(of: app\.threads\.map\(\\\.id\)\)[\s\S]{0,160}consumeLaunchThreadIntent\(\)[\s\S]{0,160}selectMostRecentThreadIfNeeded\(\)/,
"Chats retries explicit and default selection when hydration adds threads",
);
assert.match(
home,
/private func selectMostRecentThreadIfNeeded\(\) \{[\s\S]{0,500}guard selection == nil,[\s\S]{0,500}!showNewChat,[\s\S]{0,500}app\.threadToOpen == nil,[\s\S]{0,500}app\.launchThreadId == nil,[\s\S]{0,500}!app\.newChatRequested,[\s\S]{0,500}let thread = app\.mostRecentThread[\s\S]{0,180}open\(\.thread\(thread\)\)/,
"the default never overrides an explicit destination or New Chat intent",
);

// Authored navigation and discovery surfaces.
Expand Down Expand Up @@ -144,7 +159,7 @@ assert.match(glass, /UINavigationBarAppearance\.glass/,

// Quiet Portal keeps the cold connection state honest, themed, deterministic,
// and accessible without inventing stages the runtime cannot prove.
assert.match(root, /Text\("Opening the Cave"\)/, "connecting state uses the approved headline");
assert.match(root, /Text\("Entering the Cave"\)/, "connecting state uses the approved headline");
assert.match(root, /Text\("Connecting to your desktop"\)/, "connecting state names the live operation");
assert.match(root, /if let host = app\.connection\?\.host/, "connecting state renders the real saved host");
assert.match(
Expand Down
4 changes: 3 additions & 1 deletion src/components/daemon-start-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";

const settings = await readFile(new URL("./settings-shell.tsx", import.meta.url), "utf8");
const settingsShell = await readFile(new URL("./settings-shell.tsx", import.meta.url), "utf8");
const settingsDaemon = await readFile(new URL("./settings-daemon.tsx", import.meta.url), "utf8");
const settings = `${settingsShell}\n${settingsDaemon}`;
const workspace = await readFile(new URL("./workspace.tsx", import.meta.url), "utf8");

assert.match(settings, /fetch\("\/api\/daemon\/start", \{ method: "POST" \}\)/);
Expand Down
6 changes: 4 additions & 2 deletions src/components/settings-action-buttons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
import ts from "typescript";

const shell = readFileSync(new URL("./settings-shell.tsx", import.meta.url), "utf8");
const daemon = readFileSync(new URL("./settings-daemon.tsx", import.meta.url), "utf8");
const picker = readFileSync(new URL("./settings-familiar-picker.tsx", import.meta.url), "utf8");
const controls = readFileSync(new URL("./ui/settings-controls.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../app/globals.css", import.meta.url), "utf8");
Expand Down Expand Up @@ -45,12 +46,13 @@ function jsxElementBlocks(fileName: string, source: string, tagName: string): st
}

const reviewedSemanticControl = (button: string): boolean =>
/goToSetting\(e\)|selectDevice\(device\)|settings-nav__item|role="switch"|aria-pressed=|aria-label=\{`Pick \$\{label\} color`\}|aria-haspopup="dialog"|role="option"|role="radio"|role="checkbox"|setEnlarged\(true\)|Drag to reorder|familiar-studio-lifecycle__row-main/.test(
/goToSetting\(e\)|selectDevice\(device\)|settings-nav__item|role="switch"|aria-pressed=|aria-expanded=|aria-label=\{`Pick \$\{label\} color`\}|aria-haspopup="dialog"|role="option"|role="radio"|role="checkbox"|setEnlarged\(true\)|Drag to reorder|familiar-studio-lifecycle__row-main/.test(
button,
);

for (const [name, source] of [
["settings shell", shell],
["settings daemon", daemon],
["settings familiar picker", picker],
["settings segmented control", controls],
...studioSources,
Expand All @@ -72,7 +74,7 @@ for (const [name, source] of [
}
}

const saveConnectionButtons = jsxElementBlocks("settings-shell.tsx", shell, "Button").filter((block) =>
const saveConnectionButtons = jsxElementBlocks("settings-daemon.tsx", daemon, "Button").filter((block) =>
block.includes("Save connection"),
);
assert.equal(saveConnectionButtons.length, 1, "Save connection renders through exactly one shared Button");
Expand Down
97 changes: 89 additions & 8 deletions src/components/settings-daemon-multihost.test.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,107 @@
// @ts-nocheck
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";

const shell = readFileSync(new URL("./settings-shell.tsx", import.meta.url), "utf8");
const shellEntry = readFileSync(new URL("./settings-shell.tsx", import.meta.url), "utf8");
const daemonUrl = new URL("./settings-daemon.tsx", import.meta.url);
const daemon = existsSync(daemonUrl) ? readFileSync(daemonUrl, "utf8") : "";
const shell = `${shellEntry}\n${daemon}`;
const sections = readFileSync(new URL("./settings-sections.ts", import.meta.url), "utf8");
const daemonCssUrl = new URL("../styles/settings-daemon.css", import.meta.url);
const daemonCss = existsSync(daemonCssUrl) ? readFileSync(daemonCssUrl, "utf8") : "";

// ── Claude Design daemon control sheet ───────────────────────────────────────
assert.match(
shellEntry,
/import \{ DaemonSection \} from "\.\/settings-daemon"/,
"SettingsShell should delegate the daemon control sheet to a focused component",
);
assert.match(
daemon,
/export function DaemonSection/,
"the focused daemon module should export the Settings section",
);
assert.match(daemon, /className="settings-daemon"/, "the daemon page should own a responsive control-sheet container");
assert.match(daemon, /className="settings-daemon-hero"/, "the daemon page should open with the approved compact hero");
assert.match(daemon, /Settings · Daemon/, "the hero should carry the approved settings kicker");
assert.match(daemon, /className="settings-daemon-chip-list"/, "the hero should summarize target, API, queue, and uptime");
assert.match(daemon, />\s*Refresh\s*</, "the hero should expose an explicit status refresh");
assert.match(daemon, /Restart daemon/, "the hero should expose the daemon restart action");
assert.match(daemon, /Start daemon/, "the hero should make an offline local daemon actionable");

assert.match(daemon, /className="settings-daemon-status-card"/, "daemon state should render as the approved status card");
assert.match(daemon, /className="settings-daemon-status-strip"/, "the primary daemon state should lead the status card");
assert.match(daemon, /className="settings-daemon-status-grid"/, "the six daemon metrics should share a dense grid");
for (const label of [
"AUTHORITY",
"PENDING QUEUE",
"LOCAL BIND",
"STALE CACHE",
"WAKE LOCAL",
"HANDOFF",
]) {
assert.match(daemon, new RegExp(`label: "${label}"`), `the status grid should include ${label}`);
}
assert.match(daemon, />HOME</, "travel state should expose the approved HOME segment");
assert.match(daemon, />AWAY</, "travel state should expose the approved AWAY segment");
assert.match(daemon, /Manual offline/, "travel state should preserve the real manual-offline action");
assert.match(daemon, /Back online/, "manual-offline state should expose the matching recovery action");

assert.match(daemon, /className="settings-daemon-target-grid"/, "runtime targets should render as selectable cards");
assert.match(daemon, /aria-pressed=\{mode === target\.id\}/, "runtime target cards should expose selection programmatically");
assert.match(daemon, /<TextInput[\s\S]*aria-label="Server hub URL"/, "the hub field should reuse the shared text-input primitive");
assert.match(daemon, /<TextArea[\s\S]*aria-label="Executor addresses, one per line"/, "executor addresses should reuse the shared textarea primitive");
assert.match(daemon, /aria-expanded=\{executorsOpen\}/, "executor addresses should use progressive disclosure");
assert.match(daemon, /optional · multi-machine setups/, "executor disclosure should explain its advanced scope");
assert.match(daemon, /const connectionDirty =/, "connection changes should remain drafts until explicitly saved");
assert.match(
daemon,
/const normalizedHubUrl = hubUrl\.trim\(\)/,
"saving a connection should normalize surrounding whitespace from the hub URL",
);
assert.match(
daemon,
/body: JSON\.stringify\(\{ multiHost: \{ mode: nextMode, hubUrl: normalizedHubUrl, executorUrls: normalizedExecutorUrls \} \}\)/,
"the normalized hub URL and executor list should be the values persisted to config",
);
assert.match(daemon, />\s*Revert\s*</, "connection drafts should be reversible");
assert.match(daemon, />\s*Save connection\s*</, "connection drafts should have one explicit save action");

assert.match(daemon, /className="settings-daemon-info"/, "daemon metadata should use the compact approved info table");
assert.match(daemon, /copyInfoValue/, "copyable daemon paths should share one announced copy path");
assert.match(shell, /omnigentSettings=\{<OmnigentSettingsGroup \/>\}/, "the Vault-gated Omnigent settings must remain reachable from Daemon");

assert.match(
daemonCss,
/@container settings-daemon \(max-width:/,
"the control sheet should adapt to its pane with a container query",
);
assert.match(
daemonCss,
/@media \(prefers-reduced-motion: reduce\)/,
"daemon-specific motion should have an explicit reduced-motion treatment",
);
assert.doesNotMatch(
daemonCss,
/(?:gap|padding|width|height):\s*(?:2|3|6|8|12)px|box-shadow:\s*inset\s+2px/,
"daemon micro-spacing should use the design-system spacing tokens",
);
assert.doesNotMatch(
shell,
/type MultiHostMode = "local" \| "hub"/,
"SettingsShell should model local vs server hub mode explicitly",
/bg-red-400/,
"daemon error states should use the semantic danger token across every theme",
);

assert.match(
shell,
/fetch\("\/api\/config", \{ cache: "no-store", signal: ctl\.signal \}\)/,
"Daemon settings should load Cave config before rendering connection controls",
/type MultiHostMode = "local" \| "hub"/,
"SettingsShell should model local vs server hub mode explicitly",
);

assert.match(
shell,
/body: JSON\.stringify\(\{ multiHost: \{ mode: nextMode, hubUrl, executorUrls: parseExecutorUrls\(executorText\) \} \}\)/,
"Daemon settings should persist the selected connection mode through cave-config",
/fetch\("\/api\/config", \{ cache: "no-store", signal: ctl\.signal \}\)/,
"Daemon settings should load Cave config before rendering connection controls",
);

assert.match(
Expand Down
Loading
Loading