Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7b93c57
Remove the notes study material
nicksan222 Oct 3, 2026
aaec34e
Keep every version of a session entry and rewrite entries on request
nicksan222 Oct 3, 2026
c8021eb
Switch, edit and rewrite versions from the session timeline
nicksan222 Oct 3, 2026
ff81009
Keep an answer's citations when it is rewritten and stop a rewrite an…
nicksan222 Oct 3, 2026
4f7ded0
Fix keyboard focus, placement and writing states of the version controls
nicksan222 Oct 3, 2026
339fc88
Rewrite file-only entries and keep sparse citations when rewriting
nicksan222 Oct 3, 2026
a1252cc
Keep a stopped version out of the way and reach the action bar by key…
nicksan222 Oct 3, 2026
aa9347c
Show the action bar while one of its buttons has keyboard focus
nicksan222 Oct 3, 2026
774bfd3
Set entry actions quietly in the margin and trim redundant comments
nicksan222 Oct 3, 2026
1eee703
Record the demo again
nicksan222 Oct 3, 2026
f9fbfcc
Merge branch 'main' into feat/versioned-elements
nicksan222 Oct 3, 2026
6720488
Keep the student's own brackets and an unfinished answer's citations
nicksan222 Oct 3, 2026
4469896
Cite from rewritten notes and keep the citations an edit keeps
nicksan222 Oct 3, 2026
c8de2ef
Strip only the markers a version has citations for
nicksan222 Oct 4, 2026
451f279
Keep the jobs that wrote finished versions when replacing unfinished …
nicksan222 Oct 4, 2026
354230c
Renumber only real markers when rewriting a cited version
nicksan222 Oct 4, 2026
638fc73
Hold a note's versions still while it is being edited
nicksan222 Oct 4, 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 DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ drawings at 16px (14px inline), coloured `ink-2`, or `ink` when active.
ahead. While the action runs, Cancel is disabled and the alert stays.

### Navigation
- **Sidebar, top to bottom:** labelled destination rows (Home, Projects, Notes, Flashcards,
- **Sidebar, top to bottom:** labelled destination rows (Home, Projects, Flashcards,
Diagrams, Practice, Library, Activity), then the page's own list (the projects
tree on Projects), then Settings and Help pinned to the bottom. Labels and icons together,
as Codex does: no unlabelled icon rail. Search stays in the title bar with its shortcut.
Expand Down
6 changes: 3 additions & 3 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,14 @@ reviewed and practised their own material, without managing a pile of separate t
learner's own ChatGPT plan.
- **No extra subscription.** The language models run on the ChatGPT plan the learner already
has (Plus or Pro), so Study adds no AI bill.
- **From notes to exam.** The learner's own material becomes study notes, diagrams, flashcards
- **From notes to exam.** The learner's own material becomes diagrams, flashcards
on a spaced-repetition schedule, and endless graded practice, counting down to each exam.

## Operating Context

- **Projects:** one per course or subject, holding its sources, sessions and material, with
optional exam dates that Home and the flashcard reviews count down to. A project is a
group of sessions: its notes, flashcards, diagram and quiz always cover the
group of sessions: its flashcards, diagram and quiz always cover the
whole project. Each is up to date, out of date (saying what changed since) or not made
yet; Update rewrites it from the project as it is now, revising what it says.
- **Sessions are the notebook:** the learner writes, records and attaches as they study.
Expand All @@ -56,7 +56,7 @@ reviewed and practised their own material, without managing a pile of separate t

## Capabilities and Constraints

- Pages today: Home, Projects, Sessions, Study (notes, flashcards, diagrams), Practice, media lists, Pipelines
- Pages today: Home, Projects, Sessions, Study (flashcards, diagrams), Practice, media lists, Pipelines
(processing), Settings, Help, and the onboarding tour.
- Model work (reading pages and PDFs, answers, titles, material, quiz questions and grades)
runs only on the learner's ChatGPT plan. Speech-to-text and embeddings run locally, and
Expand Down
17 changes: 16 additions & 1 deletion apps/desktop/src/app/desktop.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Native application actions and menus. GPUI Kit owns their platform behavior.

use gpui_kit::{App, KeyBinding, Menu, MenuItem, actions};
use gpui_kit::{App, KeyBinding, Menu, MenuItem, NoAction, actions};
use study_localization::{Locale, Message, text};

/// The id the system knows the app and its windows by.
Expand Down Expand Up @@ -37,8 +37,23 @@ pub(crate) fn bind_shortcuts(cx: &mut App) {
#[cfg(target_os = "macos")]
KeyBinding::new("ctrl-cmd-f", ToggleFullscreen, None),
]);
bind_prompt_field(cx);
}

/// Keeps Space and Enter for a text field set in a popover. The popover binds both to its own
/// confirm, which toggles it closed; inside a [`PROMPT_FIELD`] a space is a space, and Enter
/// runs what is typed.
pub(crate) fn bind_prompt_field(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("space", NoAction, Some(PROMPT_FIELD)),
KeyBinding::new("enter", NoAction, Some(PROMPT_FIELD)),
]);
}

/// The key context of a text field set in a popover, which keeps Space and Enter from
/// reaching the popover.
pub(crate) const PROMPT_FIELD: &str = "PromptField";

/// Names the app and installs its menus, native and in the window, in `locale`.
pub(crate) fn install(locale: Locale, cx: &mut App) {
cx.set_app_identity(APP_ID, text(locale, Message::AppName));
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/features/jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ impl Problem {
}
JobKind::Title
| JobKind::Reply
| JobKind::Rewrite
| JobKind::Artifact
| JobKind::Question
| JobKind::Grade => Self::Gone,
Expand Down Expand Up @@ -102,6 +103,7 @@ pub fn job_label(kind: JobKind, source: Option<SourceKind>) -> Message {
JobKind::Embed => Message::StageEmbed,
JobKind::Title => Message::StageTitle,
JobKind::Reply => Message::StageReply,
JobKind::Rewrite => Message::StageRewrite,
JobKind::Artifact => Message::StageArtifact,
JobKind::Question => Message::StageQuestion,
JobKind::Grade => Message::StageGrade,
Expand All @@ -116,7 +118,9 @@ fn activity(kind: JobKind, source: Option<SourceKind>) -> Message {
Some(ExtractorKind::Transcription) => Message::ActivityListening,
_ => Message::ActivityReading,
},
JobKind::Reply | JobKind::Artifact | JobKind::Question => Message::ActivityWriting,
JobKind::Reply | JobKind::Rewrite | JobKind::Artifact | JobKind::Question => {
Message::ActivityWriting
}
JobKind::Grade => Message::ActivityChecking,
JobKind::Title | JobKind::Index | JobKind::Embed | JobKind::Fetch => {
Message::ActivityWorking
Expand Down
16 changes: 4 additions & 12 deletions apps/desktop/src/features/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use std::path::PathBuf;

use study_app::views::{ChatMessage, PartContent};
use study_app::views::ChatMessage;

/// Longest provisional title derived from a first message, shown until the title agent
/// names the session.
Expand All @@ -24,18 +24,10 @@ pub fn derive_title(text: &str, files: &[PathBuf]) -> Option<String> {
study_core::text::title_from(&source, MAX_DERIVED_TITLE_CHARS)
}

/// What a message says, to copy: its words, a blank line between parts as in the
/// transcript. Empty when it holds only files or study material.
/// What a message says, to copy: the text of its active version. Empty when it holds only
/// files or study material.
pub fn message_words(message: &ChatMessage) -> String {
let words: Vec<&str> = message
.parts
.iter()
.filter_map(|part| match &part.content {
PartContent::Text(body) => Some(body.as_str()),
_ => None,
})
.collect();
words.join("\n\n")
message.text().to_owned()
}

#[cfg(test)]
Expand Down
29 changes: 7 additions & 22 deletions apps/desktop/src/ui/screens/shell/page.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ enum Page {
Settings,
Help,
Pipelines,
Notes,
/// The flashcard sets, and reviewing the cards that are due.
Flashcards,
Diagrams,
Expand All @@ -90,24 +89,22 @@ enum Page {

impl Page {
/// Every page, in declaration order, so `ALL[page as usize] == page`.
const ALL: [Self; 10] = [
const ALL: [Self; 9] = [
Self::Home,
Self::MediaList,
Self::Projects,
Self::Settings,
Self::Help,
Self::Pipelines,
Self::Notes,
Self::Flashcards,
Self::Diagrams,
Self::Practice,
];

/// Every page, in the order the navigation shows them; `is_utility` picks the group.
const RAIL: [Self; 10] = [
const RAIL: [Self; 9] = [
Self::Home,
Self::Projects,
Self::Notes,
Self::Flashcards,
Self::Diagrams,
Self::Practice,
Expand All @@ -120,7 +117,6 @@ impl Page {
/// The kind of study material the page holds, for the pages of material.
fn material(self) -> Option<ArtifactKind> {
match self {
Self::Notes => Some(ArtifactKind::Notes),
Self::Flashcards => Some(ArtifactKind::Flashcards),
Self::Diagrams => Some(ArtifactKind::Diagram),
Self::Home
Expand All @@ -136,7 +132,6 @@ impl Page {
/// The page that holds study material of `kind`.
fn of_material(kind: ArtifactKind) -> Self {
match kind {
ArtifactKind::Notes => Self::Notes,
ArtifactKind::Flashcards => Self::Flashcards,
ArtifactKind::Diagram => Self::Diagrams,
}
Expand All @@ -150,7 +145,6 @@ impl Page {
Self::Settings => Message::Settings,
Self::Help => Message::Help,
Self::Pipelines => Message::Pipelines,
Self::Notes => Message::StudyNotes,
Self::Flashcards => Message::Flashcards,
Self::Diagrams => Message::Diagrams,
Self::Practice => Message::Practice,
Expand All @@ -164,7 +158,6 @@ impl Page {
| Self::Projects
| Self::Settings
| Self::Pipelines
| Self::Notes
| Self::Flashcards
| Self::Diagrams
| Self::Practice => true,
Expand All @@ -178,7 +171,6 @@ impl Page {
Self::Projects
| Self::Settings
| Self::Pipelines
| Self::Notes
| Self::Flashcards
| Self::Diagrams
| Self::Practice => true,
Expand All @@ -194,7 +186,6 @@ impl Page {
| Self::MediaList
| Self::Projects
| Self::Pipelines
| Self::Notes
| Self::Flashcards
| Self::Diagrams
| Self::Practice => false,
Expand All @@ -209,7 +200,6 @@ impl Page {
Self::Settings => IconName::Settings,
Self::Help => IconName::CircleQuestionMark,
Self::Pipelines => IconName::Workflow,
Self::Notes => pages::kind_icon(ArtifactKind::Notes),
Self::Flashcards => pages::kind_icon(ArtifactKind::Flashcards),
Self::Diagrams => pages::kind_icon(ArtifactKind::Diagram),
Self::Practice => IconName::ListChecks,
Expand Down Expand Up @@ -512,7 +502,7 @@ impl AppShell {
self.start_workers(cx);
self.load_pipelines(cx);
}
Page::Notes | Page::Flashcards | Page::Diagrams => {
Page::Flashcards | Page::Diagrams => {
self.start_workers(cx);
if let Some(kind) = page.material() {
self.enter_material(kind, cx);
Expand Down Expand Up @@ -614,12 +604,7 @@ impl Render for AppShell {
Page::Projects => self.sync_projects(window, cx),
Page::Practice => self.sync_practice(locale, window, cx),
// The pages of material sync as they are drawn, in `workspace`.
Page::MediaList
| Page::Help
| Page::Pipelines
| Page::Notes
| Page::Flashcards
| Page::Diagrams => {}
Page::MediaList | Page::Help | Page::Pipelines | Page::Flashcards | Page::Diagrams => {}
}
// The welcome tour takes the whole window while it is open.
let (body, alert) = match self.onboarding_view(locale, cx) {
Expand Down Expand Up @@ -693,7 +678,7 @@ impl AppShell {
.projects_alert(locale, cx)
.or_else(|| self.sessions_alert(locale, cx)),
Page::MediaList => self.media_alert(locale, cx),
Page::Notes | Page::Flashcards | Page::Diagrams => self.study_alert(locale, cx),
Page::Flashcards | Page::Diagrams => self.study_alert(locale, cx),
Page::Practice => self.practice_alert(locale, cx),
Page::Home | Page::Help | Page::Pipelines | Page::Settings => None,
}
Expand All @@ -714,7 +699,7 @@ impl AppShell {
Page::MediaList => Some(self.media_sidebar(locale, cx)),
Page::Projects => Some(self.project_sidebar(locale, cx)),
Page::Pipelines => Some(self.pipelines_sidebar(locale, cx)),
Page::Notes | Page::Flashcards | Page::Diagrams => Some(self.study_sidebar(locale, cx)),
Page::Flashcards | Page::Diagrams => Some(self.study_sidebar(locale, cx)),
Page::Practice => Some(self.practice_sidebar(locale, cx)),
Page::Settings => Some(self.settings_sidebar(locale, cx)),
Page::Home | Page::Help => None,
Expand All @@ -728,7 +713,7 @@ impl AppShell {
Page::MediaList => self.media_page(locale, cx),
Page::Projects => self.projects_page(locale, window, cx),
Page::Pipelines => self.pipelines_page(locale, cx),
Page::Notes | Page::Flashcards | Page::Diagrams => {
Page::Flashcards | Page::Diagrams => {
self.sync_study(locale, window, cx);
self.study_page(locale, cx)
}
Expand Down
37 changes: 37 additions & 0 deletions apps/desktop/src/ui/screens/shell/page/pages/components/job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,43 @@ pub(in crate::ui::screens::shell::page) fn job_problem_parts(
parts
}

/// What a version of an entry that failed or was stopped shows on its one line: what
/// happened, and the way to try again. Unlike [`job_problem_parts`] it never draws a card, so
/// an entry stays as short as its words.
pub(in crate::ui::screens::shell::page) fn stopped_line_parts(
retry_id: impl Into<ElementId>,
job: &Job,
retry: JobAction,
locale: Locale,
cx: &mut Context<AppShell>,
) -> Vec<AnyElement> {
let colors = cx.theme().colors;
let mut parts = Vec::new();
// A stopped version says nothing more: the switcher already names it, so only the way to
// try again is left. A failed one says why.
if job.status == JobStatus::Failed {
parts.push(
div()
.text_color(colors.danger)
.whitespace_normal()
.child(text(locale, Problem::of_job(job).explanation()))
.into_any_element(),
);
parts.push(
div()
.child(study_localization::separator())
.into_any_element(),
);
}
parts.push(
retry_button(retry_id, job, retry, locale, cx)
.ghost()
.xsmall()
.into_any_element(),
);
parts
}

/// A button to the Settings section that sets up `kind` (read from `source`), when
/// `problem` is fixed there; `None` when the fix is elsewhere. The caller sizes it.
pub(in crate::ui::screens::shell::page) fn settings_button(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ use study_ui::units;
/// What a kind of material is called.
pub(in crate::ui::screens::shell::page) fn kind_label(kind: ArtifactKind) -> Message {
match kind {
ArtifactKind::Notes => Message::KindNotes,
ArtifactKind::Flashcards => Message::KindFlashcards,
ArtifactKind::Diagram => Message::KindDiagram,
}
Expand All @@ -31,7 +30,6 @@ pub(in crate::ui::screens::shell::page) fn kind_label(kind: ArtifactKind) -> Mes
/// The icon a kind of material is drawn with.
pub(in crate::ui::screens::shell::page) fn kind_icon(kind: ArtifactKind) -> IconName {
match kind {
ArtifactKind::Notes => IconName::SquarePen,
ArtifactKind::Flashcards => IconName::Shapes,
ArtifactKind::Diagram => IconName::Workflow,
}
Expand Down Expand Up @@ -93,16 +91,8 @@ const PROSE_CITATION: &str = "prose-citation";

/// Markdown as the model writes it, read comfortably: headings by level, bullet and
/// numbered lists, quotes, code blocks, rules, **bold** picked out, and citation markers like
/// `[2]` as their number alone in a superscript chip ([`citation_mark`]).
pub(in crate::ui::screens::shell::page) fn prose(
markdown: &str,
cx: &mut Context<AppShell>,
) -> AnyElement {
prose_citing(markdown, None, cx)
}

/// [`prose`] whose citation markers open what they cite: each `[n]` a raised chip that
/// calls `cite` with `n`. `id` names the prose among the page's elements.
/// `[2]` as their number alone in a superscript chip ([`citation_mark`]). Given `cite`, each
/// `[n]` chip calls it with `n`, and `id` names the prose among the page's elements.
pub(in crate::ui::screens::shell::page) fn prose_citing(
markdown: &str,
cite: Option<(ElementId, OnCite)>,
Expand Down
10 changes: 5 additions & 5 deletions apps/desktop/src/ui/screens/shell/page/pages/components/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,15 @@ pub(in crate::ui::screens::shell::page) use field::named_field;
pub(super) use file_tile::file_tile;
pub(super) use job::{
job_controls, job_problem_parts, job_status, retry_button, settings_button, status_icon,
status_look,
status_look, stopped_line_parts,
};
pub(super) use load::{FirstLoad, status_line};
pub(in crate::ui::screens::shell::page) use material::kind_icon;
pub(super) use material::{
OnCite, kind_label, material_status, material_writing, prose, prose_citing,
};
pub(super) use material::{OnCite, kind_label, material_status, material_writing, prose_citing};
pub(super) use pill::pill;
pub(in crate::ui::screens::shell::page) use setup_card::{ChatGptState, setup_requirement};
pub(in crate::ui::screens::shell::page) use setup_card::{
ChatGptState, setup_requirement, sign_in_line,
};
pub(in crate::ui::screens::shell::page) use source_peek::SourcePeek;
pub(super) use surface::{feature_card, panel, surface};
pub(super) use text::{code_block, quiet, section_heading};
Loading