Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a893900
fix: reconcile November 5 account lock and model labels
unlockdigital Nov 6, 2026
dbaa9e9
feat(settings): resolve source control and usage credentials by refer…
unlockdigital Nov 6, 2026
eea8c66
fix: show account labels in thread lineage
unlockdigital Nov 6, 2026
0f854b8
fix: show account labels on subagent cards
unlockdigital Nov 6, 2026
70b1ed4
feat: show model and effort switch history
unlockdigital Nov 6, 2026
497adeb
feat: filter usage by provider and model
unlockdigital Nov 6, 2026
1fbcb9a
perf: bound recovery reads by thread ownership
unlockdigital Nov 6, 2026
d9e212b
perf: avoid eager thread-family loading
unlockdigital Nov 6, 2026
479e864
perf: limit sidebar draft rerenders
unlockdigital Nov 6, 2026
771fa6d
perf: reduce side-chat rerenders
unlockdigital Nov 6, 2026
fd71fb7
feat(clients): surface child approvals in parent threads
unlockdigital Nov 6, 2026
252a35b
feat(scheduler): support one-time scheduled tasks
unlockdigital Nov 6, 2026
44dcc69
feat(clients): copy thread links that open the app
unlockdigital Nov 6, 2026
e3741a9
test(web): bound side chat renders during streaming
unlockdigital Nov 6, 2026
b08a82f
fix(web): avoid stale source control token updates
unlockdigital Nov 6, 2026
40ffad5
fix(security): backport embedded Cursor URI and Claude qs fixes
unlockdigital Nov 6, 2026
e21c375
fix(settings): validate credential shapes and discard stale markers
unlockdigital Nov 6, 2026
46be4a9
fix(links): preserve scoped references without an app scheme
unlockdigital Nov 6, 2026
c834817
fix(security): pin Electron maintenance fixes and prevent downgrades
unlockdigital Nov 6, 2026
4e0ff7d
fix(settings): protect credential errors and destination changes
unlockdigital Nov 6, 2026
2fc8cc5
test(web): exercise parent updates in the side chat regression
unlockdigital Nov 6, 2026
2fd59a6
fix(settings): serialize credential reads with updates
unlockdigital Nov 6, 2026
88ec13e
test: adapt integration fixtures for schedules and mobile links
unlockdigital Nov 6, 2026
9fa7bbc
fix(secrets): bind reference reads to the settings snapshot
unlockdigital Nov 6, 2026
47d75e4
fix(provider): keep instance credentials on one settings snapshot
unlockdigital Nov 6, 2026
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 apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"@t3tools/tailscale": "workspace:*",
"dbus-next": "0.10.2",
"effect": "catalog:",
"electron": "44.10.1",
"electron": "44.10.2",
"electron-store": "^8.2.0",
"electron-updater": "^6.8.9",
"electron-webauthn": "catalog:",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import org.json.JSONObject
import org.json.JSONArray

internal object T3ComposerClipboard {
// Pasted thread links become chips in JS; packages/shared/src/threadLinks.ts parses them.
fun hasThreadLink(text: String): Boolean =
text.contains("t3-thread://v2/") || text.contains("://threads/")

fun write(context: Context, text: String, fragment: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val payload = try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -697,11 +697,24 @@ internal class SelectionAwareEditText(context: Context) : EditText(context) {

private fun pasteContextOrImages(): Boolean {
val payload = T3ComposerClipboard.read(context)
if (payload["html"]?.contains("data-t3-context-fragment=") == true) {
pasteContextListener?.invoke(payload)
return true
return when {
payload["html"]?.contains("data-t3-context-fragment=") == true -> pasteContext(payload)
pasteImagesOrInterceptedText() -> true
isSingleItemClip() && T3ComposerClipboard.hasThreadLink(payload["text"].orEmpty()) ->
pasteContext(payload)
else -> false
}
return pasteImagesOrInterceptedText()
}

// read() sees only the first item; native paste keeps every item of a longer clip.
private fun isSingleItemClip(): Boolean {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
return clipboard?.primaryClip?.itemCount == 1
}

private fun pasteContext(payload: Map<String, String>): Boolean {
pasteContextListener?.invoke(payload)
return true
}

private fun pasteImagesOrInterceptedText(): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import UIKit
enum T3ComposerClipboard {
static let fragmentType = "app.t3.context-fragment"

// Pasted thread links become chips in JS; packages/shared/src/threadLinks.ts parses them.
static func hasThreadLink(_ text: String) -> Bool {
text.contains("t3-thread://v2/") || text.contains("://threads/")
}

static func write(text: String, fragment: String) {
var items: [String: Any] = ["public.utf8-plain-text": text]
if let data = fragment.data(using: .utf8),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,12 @@ private final class ComposerTextView: UITextView {
onPasteText?(text, selectedRange)
return
}
// read() sees only the first item; native paste keeps every item of a longer pasteboard.
if !bypassTextPasteInterception, pasteboard.numberOfItems == 1,
T3ComposerClipboard.hasThreadLink(context["text", default: ""]) {
onPasteContext?(context)
return
}
super.paste(sender)
}

Expand Down
7 changes: 4 additions & 3 deletions apps/mobile/src/components/ProviderIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { resolveProviderIcon } from "@t3tools/provider-core/client";
import { resolveOfficialAcpRegistryIconUrl } from "@t3tools/contracts";
import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
import { AppText as Text } from "./ui/AppText";
import { cn } from "../lib/cn";
import { getProviderClient } from "@t3tools/client-runtime/provider-clients";

type ProviderIconProps = {
Expand Down Expand Up @@ -161,7 +162,7 @@ export function ProviderInstanceIcon(props: {
readonly displayName: string;
readonly accentColor?: string;
readonly showBadge?: boolean;
readonly surfaceColor: string;
readonly surfaceColor?: string;
}) {
const accentColor = normalizeProviderAccentColor(props.accentColor);
return (
Expand All @@ -181,14 +182,14 @@ export function ProviderInstanceIcon(props: {
style={{ position: "absolute", right: -3, bottom: -3, width: 32, alignItems: "flex-end" }}
>
<View
className={accentColor ? undefined : "bg-card"}
className={cn(!accentColor && "bg-card", !props.surfaceColor && "border-screen")}
style={{
height: 12,
minWidth: 12,
paddingHorizontal: 2,
borderRadius: 999,
borderWidth: 1,
borderColor: props.surfaceColor,
...(props.surfaceColor ? { borderColor: props.surfaceColor } : {}),
backgroundColor: accentColor,
alignItems: "center",
justifyContent: "center",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Linking from "expo-linking";
import { StackActions, useNavigation } from "@react-navigation/native";
import { Platform } from "react-native";
import { resolveThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread-reference";
Expand Down Expand Up @@ -62,6 +63,7 @@ export function HardwareKeyboardCommandProvider({
activeThreadRef === null
? null
: resolveThreadReferenceCopyTarget({
appScheme: Linking.resolveScheme({ isSilent: true }),
threadRef: activeThreadRef,
title: activeThread?.title ?? "",
pullRequests: activeThread?.pullRequests,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ const DAYS = [

function describeSchedule(task: ScheduledTask): string {
if (task.schedule.type === "webhook") return "On webhook";
if (task.schedule.type === "once") {
const runAt = new Date(task.schedule.runAt);
return `Once, ${formatRunAtDate(runAt)} at ${formatRunAtTime(runAt)}`;
}
if (task.schedule.type === "interval") return formatScheduledTaskInterval(task.schedule.everyMs);
const days = task.schedule.weekdays?.length ? repeatLabel(task.schedule.weekdays) : "Every day";
return `${days} at ${formatTime(task.schedule.timeOfDay)}`;
Expand All @@ -122,6 +126,14 @@ function formatTime(value: string): string {
return time.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}

function formatRunAtDate(value: Date): string {
return value.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" });
}

function formatRunAtTime(value: Date): string {
return value.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}

function timePickerValue(value: string): Date {
const [hours, minutes] = value.split(":").map(Number);
const time = new Date();
Expand Down Expand Up @@ -687,6 +699,7 @@ function TaskForm({
});
const submissionPending = useRef(false);
const [timePickerOpen, setTimePickerOpen] = useState(false);
const [runAtPicker, setRunAtPicker] = useState<"date" | "time" | null>(null);
const taskMissing =
draft.task !== null &&
tasks.data !== null &&
Expand All @@ -701,6 +714,9 @@ function TaskForm({
}
};

const offersOnce =
config?.environment.capabilities.oneTimeSchedules === true ||
draft.task?.schedule.type === "once";
const save = async () => {
if (
!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
Expand Down Expand Up @@ -728,6 +744,13 @@ function TaskForm({
);
return;
}
if (draft.schedule.mode === "once" && !offersOnce) {
Dialog.alert(
"Once is not available here",
"This environment runs an older T3 Code. Update it to schedule a one-time task, or pick another schedule.",
);
return;
}
const parsedFilters = parseWebhookFilterDrafts(draft.schedule.filters);
if (draft.schedule.mode === "webhook" && "error" in parsedFilters) {
Dialog.alert("Invalid filter", parsedFilters.error);
Expand Down Expand Up @@ -964,19 +987,31 @@ function TaskForm({
variant="card"
options={[
{ value: "fixed_time", label: "At a time" },
// Older servers reject one-time schedules on save.
...(offersOnce || draft.schedule.mode === "once"
? [{ value: "once" as const, label: "Once" }]
: []),
{ value: "interval", label: "Interval" },
{ value: "webhook", label: "On webhook" },
]}
selected={draft.schedule.mode}
onSelect={(mode) => {
setTimePickerOpen(false);
setRunAtPicker(null);
setDraft({
...draft,
prompt:
mode === "webhook" && !draft.prompt.trim()
? DEFAULT_WEBHOOK_PROMPT
: draft.prompt,
schedule: { ...draft.schedule, mode },
schedule: {
...draft.schedule,
mode,
runAt:
mode === "once" && !draft.schedule.runAt
? new Date(Math.ceil(Date.now() / 3_600_000) * 3_600_000).toISOString()
: draft.schedule.runAt,
},
});
}}
/>
Expand Down Expand Up @@ -1058,6 +1093,53 @@ function TaskForm({
}}
/>
</>
) : draft.schedule.mode === "once" ? (
(["date", "time"] as const).map((field) => {
const runAt = new Date(draft.schedule.runAt);
const label = field === "date" ? formatRunAtDate(runAt) : formatRunAtTime(runAt);
return (
<View key={field}>
<Pressable
accessibilityRole="button"
accessibilityLabel={`${field === "date" ? "Date" : "Time"}, ${label}`}
onPress={() => setRunAtPicker((open) => (open === field ? null : field))}
className="min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3 active:opacity-70"
>
<Text textRole="body" className="text-foreground">
{field === "date" ? "Date" : "Time"}
</Text>
<Text
textRole="footnote"
className="min-w-0 flex-1 text-right text-foreground-muted"
>
{label}
</Text>
<SymbolView
name="chevron.right"
size={14}
tintColorClassName="accent-chevron"
type="monochrome"
/>
</Pressable>
{runAtPicker === field ? (
<DateTimePicker
value={runAt}
mode={field}
display={Platform.OS === "ios" ? "spinner" : "default"}
onDismiss={() => setRunAtPicker(null)}
onValueChange={(_, selected) => {
// Android shows a dialog that closes on pick; iOS stays inline.
if (Platform.OS === "android") setRunAtPicker(null);
setDraft({
...draft,
schedule: { ...draft.schedule, runAt: selected.toISOString() },
});
}}
/>
) : null}
</View>
);
})
) : draft.schedule.mode === "webhook" ? (
<>
<WebhookScheduleDetails
Expand Down
12 changes: 11 additions & 1 deletion apps/mobile/src/features/settings/scheduledTaskDraft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@ export function scheduledTaskDefaultModel(
}

export type ScheduleDraft = {
readonly mode: "fixed_time" | "interval" | "webhook";
readonly mode: "fixed_time" | "once" | "interval" | "webhook";
readonly timeOfDay: string;
/** ISO time for a one-time run. Empty until the user picks the "Once" mode. */
readonly runAt: string;
readonly weekdays: ReadonlyArray<number>;
readonly intervalMinutes: string;
readonly signature: ScheduledTaskWebhookSignature | null;
Expand All @@ -69,6 +71,7 @@ export type ScheduleDraft = {
export const DEFAULT_SCHEDULE: ScheduleDraft = {
mode: "fixed_time",
timeOfDay: "09:00",
runAt: "",
weekdays: [1, 2, 3, 4, 5],
intervalMinutes: "15",
signature: null,
Expand All @@ -90,6 +93,8 @@ export function scheduleDraftForTask(task: Pick<ScheduledTask, "schedule">): Sch
? [...new Set(task.schedule.weekdays)].sort((a, b) => a - b)
: [0, 1, 2, 3, 4, 5, 6],
};
case "once":
return { ...DEFAULT_SCHEDULE, mode: "once", runAt: task.schedule.runAt };
case "interval":
return {
...DEFAULT_SCHEDULE,
Expand Down Expand Up @@ -167,6 +172,10 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche
: {}),
};
}
if (draft.mode === "once") {
const runAt = new Date(draft.runAt);
return Number.isNaN(runAt.getTime()) ? null : { type: "once", runAt: runAt.toISOString() };
}
if (draft.mode === "interval") {
const minutes = parseIntervalMinutes(draft.intervalMinutes);
const everyMs = Math.round(minutes * 60_000);
Expand Down Expand Up @@ -216,6 +225,7 @@ function draftSignature(draft: ScheduledTaskDraft): string {
.map((option) => [option.id, option.value]),
draft.schedule.mode,
draft.schedule.timeOfDay,
draft.schedule.runAt,
[...draft.schedule.weekdays].sort((a, b) => a - b),
draft.schedule.intervalMinutes,
draft.schedule.maxDeliveryAgeMinutes,
Expand Down
Loading
Loading