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
29 changes: 23 additions & 6 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ import {
import { readPastedComposerContext } from "./composerInlineTokenPaste";
import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste";
import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
import { useThreadActions } from "../hooks/useThreadActions";
import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions";
import {
deriveProviderSubagentStatus,
formatModelSelectionEffort,
Expand Down Expand Up @@ -250,6 +250,7 @@ import {
useThreadPreviewState,
} from "../previewStateStore";
import { BrowserSettingsReadError } from "../browser/openFileInPreview";
import { previewRuntimeTabId } from "../browser/previewRuntimeTabId";
import { addBrowserSurface } from "./preview/addBrowserSurface";
import { closePreviewSession } from "./preview/closePreviewSession";
import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";
Expand Down Expand Up @@ -2230,6 +2231,14 @@ export default function ChatView(props: ChatViewProps) {
selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef),
);
const activePreviewState = useThreadPreviewState(activeThreadRef);
const activePreviewServerEpoch = activePreviewState.serverEpoch;
const resolvePreviewRuntimeTabId = useMemo(
() =>
activeThreadRef
? (tabId: string) => previewRuntimeTabId(activeThreadRef, activePreviewServerEpoch, tabId)
: undefined,
[activeThreadRef, activePreviewServerEpoch],
);
const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) =>
selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef),
);
Expand Down Expand Up @@ -6626,14 +6635,20 @@ export default function ChatView(props: ChatViewProps) {
activeThreadShell !== null && supportsSnooze
? threadWokeAt(activeThreadShell, { now: new Date().toISOString() })
: null;
const acknowledgeThreadWoke = useAcknowledgeThreadWoke();
const acknowledgeActiveThreadWoke = useCallback(() => {
if (activeThreadRef === null || activeThreadWokeAt === null) return;
markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
}, [activeThreadRef, activeThreadWokeAt, markThreadVisited]);
// Mirror of the sidebar's Woke pill for the open thread.
const activeThreadLastVisitedAt = useUiStateStore((store) =>
acknowledgeThreadWoke(activeThreadRef, activeThreadWokeAt);
}, [acknowledgeThreadWoke, activeThreadRef, activeThreadWokeAt]);
// Mirror of the sidebar's Woke pill for the open thread. Same watermark as
// the sidebar, so an acknowledgement from any device hides it.
const activeThreadLocalVisitedAt = useUiStateStore((store) =>
activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey],
);
const activeThreadLastVisitedAt = resolveThreadLastVisitedAt(
activeThreadShell?.lastVisitedAt,
activeThreadLocalVisitedAt,
);
const activeThreadWokeVisible = useMemo(() => {
if (activeThreadWokeAt === null) return false;
if (activeThreadShell?.settledOverride === "settled") return false;
Expand All @@ -6643,7 +6658,7 @@ export default function ChatView(props: ChatViewProps) {
// above stamps it); folding that floor in here keeps a completion-
// triggered wake from flashing a banner for one frame before the stamp
// lands. An unparseable stored visit counts as never-visited: corrupt
// local data must not eat the wake signal.
// data must not eat the wake signal.
const storedVisitMs = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
const completedAtMs = activeLatestRun?.completedAt
? Date.parse(activeLatestRun.completedAt)
Expand Down Expand Up @@ -11159,6 +11174,7 @@ export default function ChatView(props: ChatViewProps) {
pendingSurfaceIds={pendingFileSurfaceIds}
previewSessions={activePreviewState.sessions}
desktopByTabId={activePreviewState.desktopByTabId}
previewRuntimeTabId={resolvePreviewRuntimeTabId}
terminalLabelsById={activeTerminalLabelsById}
onActivate={activateRightPanelSurface}
onCloseSurface={closeRightPanelSurface}
Expand Down Expand Up @@ -11213,6 +11229,7 @@ export default function ChatView(props: ChatViewProps) {
pendingSurfaceIds={pendingFileSurfaceIds}
previewSessions={activePreviewState.sessions}
desktopByTabId={activePreviewState.desktopByTabId}
previewRuntimeTabId={resolvePreviewRuntimeTabId}
terminalLabelsById={activeTerminalLabelsById}
onActivate={activateRightPanelSurface}
onCloseSurface={closeRightPanelSurface}
Expand Down
10 changes: 2 additions & 8 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ import {
getThreadKeysToDeselectAfterDelete,
useThreadSelectionStore,
} from "../threadSelectionStore";
import { useThreadActions } from "../hooks/useThreadActions";
import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions";
import { useHandleNewThread } from "../hooks/useHandleNewThread";
import { useTerminalFocus } from "../hooks/useTerminalFocus";
import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
Expand Down Expand Up @@ -2381,13 +2381,7 @@ export default function Sidebar() {
const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
const markThreadVisited = useUiStateStore((s) => s.markThreadVisited);
const acknowledgeWoke = useCallback(
(threadRef: ScopedThreadRef, visitedAt: string) => {
markThreadVisited(scopedThreadKey(threadRef), visitedAt);
},
[markThreadVisited],
);
const acknowledgeWoke = useAcknowledgeThreadWoke();
const routeTarget = useParams({
strict: false,
select: (params) => resolveThreadRouteTarget(params),
Expand Down
58 changes: 57 additions & 1 deletion apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
type ResponseStreamingMode,
MIN_TERMINAL_FONT_SIZE,
type QuitConfirmationMode,
SidebarProjectSortOrder,
} from "@t3tools/contracts/settings";
import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
import { createModelSelection } from "@t3tools/shared/model";
Expand Down Expand Up @@ -87,6 +88,7 @@ import { useDesktopUpdateState } from "../../state/desktopUpdate";
import {
getCustomModelOptionsByInstance,
resolveAppModelSelectionState,
selectsPlanAgent,
} from "../../modelSelection";
import {
applyProviderInstanceSettings,
Expand Down Expand Up @@ -181,6 +183,13 @@ const RESPONSE_STREAMING_MODE_DESCRIPTIONS: Record<ResponseStreamingMode, string
paragraph: "Each paragraph or code block appears as soon as it is complete.",
};

const SIDEBAR_PROJECT_SORT_ORDER_LABELS: Record<SidebarProjectSortOrder, string> = {
updated_at: "Last user message",
created_at: "Created at",
manual: "Manual",
};
const isSidebarProjectSortOrder = Schema.is(SidebarProjectSortOrder);

const TIMESTAMP_FORMAT_LABELS = {
locale: "System default",
"12-hour": "12-hour",
Expand Down Expand Up @@ -552,6 +561,9 @@ export function useSettingsRestore(onRestored?: () => void) {
DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode
? ["Project Grouping"]
: []),
...(settings.sidebarProjectSortOrder !== DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder
? ["Project order"]
: []),
...(settings.sidebarWorkingShelfEnabled !==
DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled
? ["Working section"]
Expand Down Expand Up @@ -690,6 +702,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.autoResumeLimitedThreads,
settings.snoozeLimitedThreads,
settings.sidebarProjectGroupingMode,
settings.sidebarProjectSortOrder,
settings.sidebarWorkingShelfEnabled,
settings.sidebarThreadPreviewCount,
settings.showSkillsInSlashMenu,
Expand Down Expand Up @@ -789,6 +802,7 @@ export function useSettingsRestore(onRestored?: () => void) {
panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs,
sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder,
sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled,
sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
Expand Down Expand Up @@ -2270,6 +2284,46 @@ export function GeneralSettingsPanel() {
/>
}
/>
<SettingsRow
{...searchableSetting("project-order")}
description="Order of projects in the sidebar project picker and command palette."
resetAction={
settings.sidebarProjectSortOrder !==
DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder ? (
<SettingResetButton
label="project order"
onClick={() =>
updateSettings({
sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder,
})
}
/>
) : null
}
control={
<Select
value={settings.sidebarProjectSortOrder}
onValueChange={(value) => {
if (isSidebarProjectSortOrder(value)) {
updateSettings({ sidebarProjectSortOrder: value });
}
}}
>
<SelectTrigger size="sm" className="w-full sm:w-44" aria-label="Project order">
<SelectValue>
{SIDEBAR_PROJECT_SORT_ORDER_LABELS[settings.sidebarProjectSortOrder]}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
{SidebarProjectSortOrder.literals.map((sortOrder) => (
<SelectItem hideIndicator key={sortOrder} value={sortOrder}>
{SIDEBAR_PROJECT_SORT_ORDER_LABELS[sortOrder]}
</SelectItem>
))}
</SelectPopup>
</Select>
}
/>

<SettingsRow
serverScoped
Expand Down Expand Up @@ -3244,7 +3298,9 @@ export function GeneralSettingsPanel() {
onPromptChange={() => {}}
modelOptions={textGenModelOptions}
allowPromptInjectedEffort={false}
planModeEnabled={settings.planModeEnabled}
planModeEnabled={
settings.planModeEnabled || selectsPlanAgent(textGenModelOptions)
}
triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
onModelOptionsChange={(nextOptions) => {
updateSettings({
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,12 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/general",
searchTerms: ["combine matching repositories environments sidebar"],
},
{
id: "project-order",
title: "Project order",
to: "/settings/general",
searchTerms: ["sort projects sidebar manual created recent"],
},
{
id: "snooze-limited-threads",
title: "Snooze limited threads",
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/hooks/useThreadActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,30 @@ function useMarkThreadUnread() {
);
}

/**
* Clears a thread's Woke marker by recording a visit at the wake time.
* Servers with visited tracking own the watermark (thread.visit keeps the
* later of the stored and supplied values, so this syncs to every device);
* older servers keep the browser-local watermark.
*/
export function useAcknowledgeThreadWoke() {
const visitThreadMutation = useAtomCommand(threadEnvironment.visit, { reportFailure: false });
const markThreadVisited = useUiStateStore((state) => state.markThreadVisited);
return useCallback(
(target: ScopedThreadRef, wokeAt: string) => {
if (readEnvironmentSupportsVisitedTracking(target.environmentId)) {
void visitThreadMutation({
environmentId: target.environmentId,
input: { threadId: target.threadId, visitedAt: wokeAt },
});
return;
}
markThreadVisited(scopedThreadKey(target), wokeAt);
},
[markThreadVisited, visitThreadMutation],
);
}

export function useThreadActions() {
const closeTerminal = useAtomCommand(terminalEnvironment.close);
const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, {
Expand Down
109 changes: 35 additions & 74 deletions apps/web/src/modelSelection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ import {
getAppModelOptionsForInstance,
resolveAppModelSelectionForInstance,
resolveAppModelSelectionState,
resolvePlanAgentHealPatch,
withoutPlanAgentSelection,
} from "./modelSelection";

function provider(input: {
Expand Down Expand Up @@ -865,81 +863,44 @@ describe("instance-scoped model selection", () => {
});
});

describe("withoutPlanAgentSelection", () => {
const instance = ProviderInstanceId.make("opencode");
describe("resolveAppModelSelectionState with the opencode plan agent", () => {
const instanceId = ProviderInstanceId.make("opencode");
const model = "opencode/gpt-5.4";

it("drops a stored plan agent option", () => {
const selection = createModelSelection(instance, model, [
{ id: "variant", value: "high" },
const opencode: ServerProvider = {
...provider({ provider: ProviderDriverKind.make("opencode"), instanceId: "opencode" }),
models: [
{
slug: model,
name: model,
isCustom: false,
capabilities: {
optionDescriptors: [
{
id: "agent",
label: "Agent",
type: "select",
options: [
{ id: "build", label: "Build", isDefault: true },
{ id: "plan", label: "Plan" },
],
currentValue: "build",
},
],
},
},
],
};
const settings: UnifiedSettings = {
...DEFAULT_UNIFIED_SETTINGS,
planModeEnabled: false,
textGenerationModelSelection: createModelSelection(instanceId, model, [
{ id: "agent", value: "plan" },
]);
expect(withoutPlanAgentSelection(selection)).toEqual(
createModelSelection(instance, model, [{ id: "variant", value: "high" }]),
);
});

it("keeps non-plan agent options", () => {
const selection = createModelSelection(instance, model, [{ id: "agent", value: "build" }]);
expect(withoutPlanAgentSelection(selection)).toBe(selection);
});

it("omits options entirely when plan was the only stored option", () => {
const selection = createModelSelection(instance, model, [{ id: "agent", value: "plan" }]);
expect(withoutPlanAgentSelection(selection)).toEqual({ instanceId: instance, model });
});

it("returns null and undefined selections unchanged", () => {
expect(withoutPlanAgentSelection(null)).toBeNull();
expect(withoutPlanAgentSelection(undefined)).toBeUndefined();
});
});

describe("resolvePlanAgentHealPatch", () => {
const instance = ProviderInstanceId.make("opencode");
const model = "opencode/gpt-5.4";
const healed = createModelSelection(instance, model, [{ id: "variant", value: "high" }]);
const storedPlan = createModelSelection(instance, model, [
{ id: "variant", value: "high" },
{ id: "agent", value: "plan" },
]);
const nullPatch = {
planModeEnabled: true,
textGenerationModelSelection: storedPlan,
sourceControlWriterModelSelection: null,
]),
};

it("returns null when plan mode is on", () => {
expect(resolvePlanAgentHealPatch(nullPatch)).toBeNull();
});

it("returns null when nothing needs healing", () => {
expect(
resolvePlanAgentHealPatch({
planModeEnabled: false,
textGenerationModelSelection: healed,
sourceControlWriterModelSelection: null,
}),
).toBeNull();
});

it("patches the stored text generation selection to drop the plan agent", () => {
expect(
resolvePlanAgentHealPatch({
planModeEnabled: false,
textGenerationModelSelection: storedPlan,
sourceControlWriterModelSelection: null,
}),
).toEqual({ textGenerationModelSelection: healed });
});

it("patches a stored source control writer selection that uses the plan agent", () => {
expect(
resolvePlanAgentHealPatch({
planModeEnabled: false,
textGenerationModelSelection: healed,
sourceControlWriterModelSelection: storedPlan,
}),
).toEqual({ sourceControlWriterModelSelection: healed });
it("keeps a stored plan agent this device did not pick", () => {
expect(resolveAppModelSelectionState(settings, [opencode])).toEqual(
createModelSelection(instanceId, model, [{ id: "agent", value: "plan" }]),
);
});
});
Loading
Loading